feat: 优化web

This commit is contained in:
2026-04-23 18:58:13 +08:00
commit 544a2f3428
160 changed files with 27327 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
import { getOAuthClientId, getOAuthRedirectUri, getPublicApiOrigin } from '@/lib/env';
import { createPKCEPair } from '@/lib/oauth/pkce';
import type { ApiEnvelope } from '@/lib/api/types';
export type TokenPair = {
accessToken: string;
refreshToken: string;
expiresIn: number;
};
/** 用户名密码 + PKCE 换 authorization_code,再 POST /oauth/token。 */
export async function loginWithPassword(params: {
userName: string;
password: string;
tenantId?: string;
state?: string;
}): Promise<TokenPair> {
const origin = getPublicApiOrigin();
const clientId = getOAuthClientId();
const redirectUri = getOAuthRedirectUri();
const { verifier, challenge } = await createPKCEPair();
const res = await fetch(`${origin}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
tenant_id: params.tenantId ?? '',
user_name: params.userName,
password: params.password,
client_id: clientId,
redirect_uri: redirectUri,
code_challenge: challenge,
code_challenge_method: 'S256',
state: params.state ?? '',
scope: 'openid',
}),
});
const json = (await res.json()) as ApiEnvelope<{
authorization_code?: string;
state?: string;
}>;
if (res.status === 401) {
throw new Error(json.msg || '用户名或密码错误');
}
if (res.status === 403) {
throw new Error(json.msg || '无权限');
}
if (!res.ok) {
throw new Error(json.msg || `HTTP ${res.status}`);
}
if (json.code !== 200 || !json.data?.authorization_code) {
throw new Error(json.msg || '登录失败');
}
return exchangeCodeForTokens({
code: json.data.authorization_code,
codeVerifier: verifier,
clientId,
redirectUri,
});
}
export async function exchangeCodeForTokens(params: {
code: string;
codeVerifier: string;
clientId: string;
redirectUri: string;
}): Promise<TokenPair> {
const origin = getPublicApiOrigin();
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: params.code,
redirect_uri: params.redirectUri,
client_id: params.clientId,
code_verifier: params.codeVerifier,
});
const res = await fetch(`${origin}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
const data = (await res.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
error?: string;
error_description?: string;
};
if (!res.ok || !data.access_token) {
throw new Error(data.error_description || data.error || '换取 access_token 失败');
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? '',
expiresIn: typeof data.expires_in === 'number' ? data.expires_in : 0,
};
}
export async function refreshAccessToken(refreshToken: string): Promise<TokenPair> {
const origin = getPublicApiOrigin();
const clientId = getOAuthClientId();
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: clientId,
});
const res = await fetch(`${origin}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
const data = (await res.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
error?: string;
error_description?: string;
};
if (!res.ok || !data.access_token) {
throw new Error(data.error_description || data.error || '刷新 token 失败');
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? refreshToken,
expiresIn: typeof data.expires_in === 'number' ? data.expires_in : 0,
};
}
export async function logoutRequest(): Promise<void> {
const origin = getPublicApiOrigin();
const res = await fetch(`${origin}/api/v1/auth/logout`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { msg?: string };
throw new Error(j.msg || '登出失败');
}
}
/** RFC 7662 风格,用于解析 opaque access_token 的 `sub`(用户 id */
export async function introspectAccessToken(accessToken: string): Promise<{
active: boolean;
sub?: string;
scope?: string;
exp?: number;
client_id?: string;
}> {
const origin = getPublicApiOrigin();
const body = new URLSearchParams({
token: accessToken,
token_type_hint: 'access_token',
});
const res = await fetch(`${origin}/oauth/introspect`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
const data = (await res.json()) as {
active?: boolean;
sub?: string;
scope?: string;
exp?: number;
client_id?: string;
};
return {
active: Boolean(data.active),
sub: data.sub,
scope: data.scope,
exp: data.exp,
client_id: data.client_id,
};
}
+111
View File
@@ -0,0 +1,111 @@
import { getPublicApiOrigin } from '@/lib/env';
import { emit401Unauthorized, emit403Forbidden } from '@/lib/notify/auth-events';
import { refreshTokensShared } from '@/lib/api/refresh-flight';
import { ApiError, type ApiEnvelope } from '@/lib/api/types';
type GetTokens = () => { accessToken: string | null; refreshToken: string | null };
type SetTokens = (access: string | null, refresh: string | null) => void;
let tokenBridge: { get: GetTokens; set: SetTokens } | null = null;
/** 由 auth store 在客户端挂载时注册,避免循环依赖。 */
export function registerTokenBridge(get: GetTokens, set: SetTokens) {
tokenBridge = { get, set };
}
let tenantIdGetter: () => string | null = () => null;
/** 由 tenant store 注册,请求 `/api/v1` 时附加 `X-Tenant-ID`(可选)。 */
export function registerTenantHeaderBridge(get: () => string | null) {
tenantIdGetter = get;
}
function authHeader(): HeadersInit {
const t = tokenBridge?.get().accessToken;
return t ? { Authorization: `Bearer ${t}` } : {};
}
function tenantHeader(): HeadersInit {
const tid = tenantIdGetter?.();
return tid ? { 'X-Tenant-ID': tid } : {};
}
/** 业务 JSONHTTP 200 时解析 envelope401 时共享 refresh 后重试一次(登录请求勿走此逻辑)。 */
export async function apiJson<T>(
path: string,
init: RequestInit & { skipRefreshRetry?: boolean } = {}
): Promise<T> {
const { skipRefreshRetry, ...req } = init;
const url = `${getPublicApiOrigin()}${path.startsWith('/') ? path : `/${path}`}`;
const method = (req.method || 'GET').toUpperCase();
const hasBody = req.body != null && req.body !== '';
const jsonHeaders: HeadersInit =
method !== 'GET' && method !== 'HEAD' && hasBody ? { 'Content-Type': 'application/json' } : {};
const baseHeaders = {
...jsonHeaders,
...authHeader(),
...tenantHeader(),
...(req.headers || {}),
};
const first = await fetch(url, {
...req,
headers: baseHeaders,
credentials: 'include',
});
if (first.status === 401 && !skipRefreshRetry && tokenBridge) {
const ok = await refreshTokensShared(
() => tokenBridge!.get().refreshToken,
(a, r) => tokenBridge!.set(a, r)
);
if (ok) {
const second = await fetch(url, {
...req,
headers: {
...jsonHeaders,
...authHeader(),
...tenantHeader(),
...(req.headers || {}),
},
credentials: 'include',
});
return handleEnvelope<T>(second);
}
}
return handleEnvelope<T>(first);
}
async function handleEnvelope<T>(res: Response): Promise<T> {
const text = await res.text();
let body: unknown = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
throw new ApiError('响应不是合法 JSON', res.status, text);
}
if (res.status === 401) {
const msg = (body as { msg?: string })?.msg || '未授权';
emit401Unauthorized(msg);
throw new ApiError(msg, 401, body);
}
if (res.status === 403) {
const msg = (body as { msg?: string })?.msg || '禁止访问';
emit403Forbidden(msg);
throw new ApiError(msg, 403, body);
}
if (!res.ok) {
throw new ApiError(`HTTP ${res.status}`, res.status, body);
}
const env = body as ApiEnvelope<T>;
if (typeof env?.code !== 'number') {
return body as T;
}
if (env.code !== 200) {
throw new ApiError(env.msg || '业务失败', res.status, env);
}
return env.data as T;
}
+99
View File
@@ -0,0 +1,99 @@
import { apiJson } from '@/lib/api/client';
import { API_V1 } from '@/lib/api/paths';
import type { DeptNode, IamDept } from '@/lib/api/types/dept';
import type { MenuNode } from '@/lib/api/types/menu';
import type { IamRole } from '@/lib/api/types/role';
import type { IamTenant } from '@/lib/api/types/tenant';
import type { IamUser } from '@/lib/api/types/user';
const B = `${API_V1}/iam`;
/** 列表响应信封 */
type ListEnvelope<T> = { items: T[]; total: number };
/** 租户 */
export const iamTenant = {
create: (body: Partial<IamTenant>) =>
apiJson<IamTenant>(`${B}/tenant/create`, { method: 'POST', body: JSON.stringify(body) }),
update: (id: string, body: Partial<IamTenant>) =>
apiJson<IamTenant>(`${B}/tenant/update/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
deleteBatch: (ids: string[]) =>
apiJson<void>(`${B}/tenant/delete-batch`, { method: 'DELETE', body: JSON.stringify(ids) }),
get: (id: string) => apiJson<IamTenant>(`${B}/tenant/get/${encodeURIComponent(id)}`),
list: (query?: Record<string, string>) => {
const q = query ? new URLSearchParams(query).toString() : 'page=1&page_size=200';
return apiJson<ListEnvelope<IamTenant>>(`${B}/tenant/list?${q}`);
},
};
/** 部门 */
export const iamDept = {
create: (body: Partial<IamDept>) =>
apiJson<IamDept>(`${B}/dept/create`, { method: 'POST', body: JSON.stringify(body) }),
update: (id: string, body: Partial<IamDept>) =>
apiJson<IamDept>(`${B}/dept/update/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
deleteBatch: (ids: string[]) =>
apiJson<void>(`${B}/dept/delete-batch`, { method: 'DELETE', body: JSON.stringify(ids) }),
get: (id: string) => apiJson<IamDept>(`${B}/dept/get/${encodeURIComponent(id)}`),
tree: () => apiJson<DeptNode[]>(`${B}/dept/tree`),
};
/** 角色 */
export const iamRole = {
create: (body: Partial<IamRole>) =>
apiJson<IamRole>(`${B}/role/create`, { method: 'POST', body: JSON.stringify(body) }),
update: (id: string, body: Partial<IamRole>) =>
apiJson<IamRole>(`${B}/role/update/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
deleteBatch: (ids: string[]) =>
apiJson<void>(`${B}/role/delete-batch`, { method: 'DELETE', body: JSON.stringify(ids) }),
get: (id: string) => apiJson<IamRole>(`${B}/role/get/${encodeURIComponent(id)}`),
list: (query?: Record<string, string>) => {
const q = query ? new URLSearchParams(query).toString() : 'page=1&page_size=50';
return apiJson<ListEnvelope<IamRole>>(`${B}/role/list?${q}`);
},
};
/** 用户 */
export const iamUser = {
create: (body: Partial<IamUser>) =>
apiJson<IamUser>(`${B}/user/create`, { method: 'POST', body: JSON.stringify(body) }),
update: (id: string, body: Partial<IamUser>) =>
apiJson<IamUser>(`${B}/user/update/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
deleteBatch: (ids: string[]) =>
apiJson<void>(`${B}/user/delete-batch`, { method: 'DELETE', body: JSON.stringify(ids) }),
get: (id: string) => apiJson<IamUser>(`${B}/user/get/${encodeURIComponent(id)}`),
list: (query?: Record<string, string>) => {
const q = query ? new URLSearchParams(query).toString() : 'page=1&page_size=20';
return apiJson<ListEnvelope<IamUser>>(`${B}/user/list?${q}`);
},
};
/** 菜单 */
export const iamMenu = {
create: (body: Partial<MenuNode>) =>
apiJson<MenuNode>(`${B}/menu/create`, { method: 'POST', body: JSON.stringify(body) }),
update: (id: string, body: Partial<MenuNode>) =>
apiJson<MenuNode>(`${B}/menu/update/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
deleteBatch: (ids: string[]) =>
apiJson<void>(`${B}/menu/delete-batch`, { method: 'DELETE', body: JSON.stringify(ids) }),
get: (id: string) => apiJson<MenuNode>(`${B}/menu/get/${encodeURIComponent(id)}`),
tree: () => apiJson<MenuNode[]>(`${B}/menu/tree`),
/** 当前用户可见导航树(需 Bearer,后端从 token 解析 user_id */
nav: () => apiJson<MenuNode[]>(`${B}/menu/nav`),
perms: () => apiJson<{ perms: string[] }>(`${B}/menu/perms`),
};
+2
View File
@@ -0,0 +1,2 @@
/** 与 Go `apiGroup` 前缀一致 */
export const API_V1 = '/api/v1';
+33
View File
@@ -0,0 +1,33 @@
import { refreshAccessToken } from '@/lib/api/auth';
let inFlight: Promise<boolean> | null = null;
/**
* 并发 401 时共享同一次 refresh,避免重复 POST /oauth/token。
* refresh 失败会清空 token(由调用方传入的 setTokens 执行)。
*/
export function refreshTokensShared(
getRefreshToken: () => string | null,
setTokens: (access: string | null, refresh: string | null) => void
): Promise<boolean> {
if (inFlight) {
return inFlight;
}
inFlight = (async () => {
try {
const rt = getRefreshToken();
if (!rt) {
return false;
}
const pair = await refreshAccessToken(rt);
setTokens(pair.accessToken, pair.refreshToken);
return true;
} catch {
setTokens(null, null);
return false;
} finally {
inFlight = null;
}
})();
return inFlight;
}
+27
View File
@@ -0,0 +1,27 @@
import { apiJson } from '@/lib/api/client';
import { API_V1 } from '@/lib/api/paths';
import type { SystemParam } from '@/lib/api/types/system-param';
const B = `${API_V1}/system/param`;
type ListEnvelope<T> = { items: T[]; total: number };
export const systemParam = {
create: (body: Partial<SystemParam>) =>
apiJson<SystemParam>(`${B}/create`, { method: 'POST', body: JSON.stringify(body) }),
update: (id: string, body: Partial<SystemParam>) =>
apiJson<SystemParam>(`${B}/update/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
deleteBatch: (ids: string[]) =>
apiJson<void>(`${B}/delete-batch`, { method: 'DELETE', body: JSON.stringify(ids) }),
get: (query: Record<string, string>) => {
const q = new URLSearchParams(query).toString();
return apiJson<SystemParam>(`${B}/get?${q}`);
},
list: (query?: Record<string, string>) => {
const q = query ? new URLSearchParams(query).toString() : '';
return apiJson<ListEnvelope<SystemParam>>(q ? `${B}/list?${q}` : `${B}/list`);
},
};
+17
View File
@@ -0,0 +1,17 @@
/** 与 Go `/api/v1` JSON 信封一致(见 docs/auth-api.md */
export type ApiEnvelope<T> = {
code: number;
msg: string;
data: T | null;
};
export class ApiError extends Error {
constructor(
message: string,
public readonly httpStatus: number,
public readonly body?: unknown
) {
super(message);
this.name = 'ApiError';
}
}
+18
View File
@@ -0,0 +1,18 @@
/** 与 Go `iam/entity.Dept` JSON 对齐 */
export type IamDept = {
id: string;
tenant_id: string;
parent_id: string;
dept_name: string;
dept_path: string;
leader_id?: string | null;
sort_order: number;
status: number;
created_at: string;
updated_at: string;
};
/** 与 Go `iam/service.DeptNode` JSON 对齐(递归树) */
export type DeptNode = IamDept & {
children?: DeptNode[];
};
+15
View File
@@ -0,0 +1,15 @@
/** 与 Go `iam/service.MenuNode` / `entity.Menu` JSON 对齐 */
export type MenuNode = {
id: string;
parent_id: string;
menu_name: string;
menu_type: number;
perms?: string;
path: string;
component?: string;
icon?: string;
sort_order?: number;
is_visible?: boolean;
external_link?: string;
children?: MenuNode[];
};
+13
View File
@@ -0,0 +1,13 @@
/** 与 Go `iam/entity.Role` JSON 对齐 */
export type IamRole = {
id: string;
tenant_id: string;
role_code: string;
role_name: string;
data_scope: number;
description: string;
is_builtin: boolean;
status: number;
created_at: string;
updated_at: string;
};
+13
View File
@@ -0,0 +1,13 @@
/** 与 Go `system/entity.SystemParam` JSON 对齐 */
export type SystemParam = {
id: string;
param_key: string;
param_value: string;
param_type: string;
param_group: string;
param_desc: string;
creator_id: string;
create_time?: string | null;
last_updater_id: string;
update_time?: string | null;
};
+11
View File
@@ -0,0 +1,11 @@
/** 与 Go `iam/entity.Tenant` JSON 对齐 */
export type IamTenant = {
id: string;
tenant_code: string;
tenant_name: string;
admin_user_id?: string | null;
status: number;
expire_time?: string | null;
created_at: string;
updated_at: string;
};
+13
View File
@@ -0,0 +1,13 @@
/** 与后端 iam_user JSON 对齐(展示用字段子集) */
export type IamUser = {
id: string;
tenant_id: string;
dept_id?: string | null;
user_name: string;
real_name?: string;
phone?: string;
email?: string;
avatar?: string;
gender?: number;
status?: number;
};