feat: 优化web
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 } : {};
|
||||
}
|
||||
|
||||
/** 业务 JSON:HTTP 200 时解析 envelope;401 时共享 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;
|
||||
}
|
||||
@@ -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`),
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
/** 与 Go `apiGroup` 前缀一致 */
|
||||
export const API_V1 = '/api/v1';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/** 浏览器可读的 API 根(scheme + host + port),不含路径。生产环境务必配置 NEXT_PUBLIC_API_ORIGIN。 */
|
||||
export function getPublicApiOrigin(): string {
|
||||
const o =
|
||||
process.env.NEXT_PUBLIC_API_ORIGIN ||
|
||||
(process.env.NODE_ENV === 'development' ? 'http://127.0.0.1:8000' : '');
|
||||
if (!o) {
|
||||
throw new Error('NEXT_PUBLIC_API_ORIGIN is not set');
|
||||
}
|
||||
return o.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export function getOAuthClientId(): string {
|
||||
const id =
|
||||
process.env.NEXT_PUBLIC_OAUTH_CLIENT_ID ||
|
||||
(process.env.NODE_ENV === 'development' ? 'spa' : '');
|
||||
if (!id) {
|
||||
throw new Error('NEXT_PUBLIC_OAUTH_CLIENT_ID is not set');
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function getOAuthRedirectUri(): string {
|
||||
const u =
|
||||
process.env.NEXT_PUBLIC_OAUTH_REDIRECT_URI ||
|
||||
(process.env.NODE_ENV === 'development' ? 'http://localhost:3000/oauth/callback' : '');
|
||||
if (!u) {
|
||||
throw new Error('NEXT_PUBLIC_OAUTH_REDIRECT_URI is not set');
|
||||
}
|
||||
return u;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
type UseApiState<T> = {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 通用数据获取 hook,封装 loading / error / data 状态 + AbortController 取消。
|
||||
*
|
||||
* @param fetcher 返回 Promise 的数据获取函数,接收 AbortSignal
|
||||
* @param deps 依赖数组,变化时重新请求
|
||||
*/
|
||||
export function useApi<T>(
|
||||
fetcher: (signal?: AbortSignal) => Promise<T>,
|
||||
deps: unknown[] = [],
|
||||
): UseApiState<T> & { refetch: () => void } {
|
||||
const [state, setState] = useState<UseApiState<T>>({
|
||||
data: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const fetcherRef = useRef(fetcher);
|
||||
fetcherRef.current = fetcher;
|
||||
|
||||
const load = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
const ac = new AbortController();
|
||||
abortRef.current = ac;
|
||||
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
|
||||
fetcherRef
|
||||
.current(ac.signal)
|
||||
.then((data) => {
|
||||
if (!ac.signal.aborted) {
|
||||
setState({ data, loading: false, error: null });
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!ac.signal.aborted) {
|
||||
setState({ data: null, loading: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
});
|
||||
}, deps);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
return { ...state, refetch: load };
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import type { MenuNode } from '@/lib/api/types/menu';
|
||||
|
||||
const HOVER_LEAVE_MS = 280;
|
||||
|
||||
type FlyoutState = {
|
||||
flyoutRoot: MenuNode | null;
|
||||
l1AnchorRect: DOMRect | null;
|
||||
openFlyout: (node: MenuNode, anchorEl: HTMLDivElement) => void;
|
||||
scheduleCloseFlyout: () => void;
|
||||
clearCloseTimer: () => void;
|
||||
closeFlyoutNow: () => void;
|
||||
toggleFlyoutClick: (node: MenuNode, wrapper: HTMLDivElement | null) => void;
|
||||
l1AnchorElRef: React.RefObject<HTMLDivElement | null>;
|
||||
railScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
/** 从 ClassicCollapsedSidebar 提取的 flyout 状态管理逻辑 */
|
||||
export function useFlyoutState(pathname: string): FlyoutState {
|
||||
const [flyoutRoot, setFlyoutRoot] = useState<MenuNode | null>(null);
|
||||
const [l1AnchorRect, setL1AnchorRect] = useState<DOMRect | null>(null);
|
||||
const l1AnchorElRef = useRef<HTMLDivElement | null>(null);
|
||||
const railScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearCloseTimer = useCallback(() => {
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeFlyout = useCallback(() => {
|
||||
setFlyoutRoot(null);
|
||||
setL1AnchorRect(null);
|
||||
l1AnchorElRef.current = null;
|
||||
}, []);
|
||||
|
||||
const scheduleCloseFlyout = useCallback(() => {
|
||||
clearCloseTimer();
|
||||
closeTimerRef.current = setTimeout(() => {
|
||||
closeFlyout();
|
||||
closeTimerRef.current = null;
|
||||
}, HOVER_LEAVE_MS);
|
||||
}, [clearCloseTimer, closeFlyout]);
|
||||
|
||||
const closeFlyoutNow = useCallback(() => {
|
||||
clearCloseTimer();
|
||||
closeFlyout();
|
||||
}, [clearCloseTimer, closeFlyout]);
|
||||
|
||||
const syncL1Anchor = useCallback(() => {
|
||||
const el = l1AnchorElRef.current;
|
||||
if (el) setL1AnchorRect(el.getBoundingClientRect());
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (flyoutRoot) syncL1Anchor();
|
||||
}, [flyoutRoot, syncL1Anchor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!flyoutRoot) return;
|
||||
const onResize = () => syncL1Anchor();
|
||||
window.addEventListener('resize', onResize);
|
||||
const rs = railScrollRef.current;
|
||||
rs?.addEventListener('scroll', onResize, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
rs?.removeEventListener('scroll', onResize);
|
||||
};
|
||||
}, [flyoutRoot, syncL1Anchor]);
|
||||
|
||||
useEffect(() => {
|
||||
closeFlyout();
|
||||
}, [pathname, closeFlyout]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') closeFlyout();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [closeFlyout]);
|
||||
|
||||
useEffect(() => clearCloseTimer, [clearCloseTimer]);
|
||||
|
||||
const openFlyout = useCallback(
|
||||
(node: MenuNode, anchorEl: HTMLDivElement) => {
|
||||
clearCloseTimer();
|
||||
l1AnchorElRef.current = anchorEl;
|
||||
setL1AnchorRect(anchorEl.getBoundingClientRect());
|
||||
setFlyoutRoot(node);
|
||||
},
|
||||
[clearCloseTimer],
|
||||
);
|
||||
|
||||
const toggleFlyoutClick = useCallback(
|
||||
(node: MenuNode, wrapper: HTMLDivElement | null) => {
|
||||
setFlyoutRoot((prev) => {
|
||||
if (prev?.id === node.id) {
|
||||
setL1AnchorRect(null);
|
||||
l1AnchorElRef.current = null;
|
||||
return null;
|
||||
}
|
||||
if (wrapper) {
|
||||
l1AnchorElRef.current = wrapper;
|
||||
setL1AnchorRect(wrapper.getBoundingClientRect());
|
||||
return node;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
flyoutRoot,
|
||||
l1AnchorRect,
|
||||
openFlyout,
|
||||
scheduleCloseFlyout,
|
||||
clearCloseTimer,
|
||||
closeFlyoutNow,
|
||||
toggleFlyoutClick,
|
||||
l1AnchorElRef,
|
||||
railScrollRef,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTabStore } from '@/stores/tab-store';
|
||||
|
||||
/** 站内导航 + 多标签联动,替代 onMenuNavigate prop drilling */
|
||||
export function useMenuNavigation() {
|
||||
const router = useRouter();
|
||||
|
||||
return (path: string, title: string) => {
|
||||
useTabStore.getState().openOrActivate({ path, title });
|
||||
router.push(path);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { iamMenu } from '@/lib/api/iam';
|
||||
import type { MenuNode } from '@/lib/api/types/menu';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useLayoutStore } from '@/stores/layout-store';
|
||||
import { useTenantStore } from '@/stores/tenant-store';
|
||||
|
||||
type NavMenuState = {
|
||||
items: MenuNode[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
authed: boolean;
|
||||
permissions: string[];
|
||||
};
|
||||
|
||||
/** 从 AppChrome 提取的导航菜单数据获取逻辑 */
|
||||
export function useNavMenu(): NavMenuState {
|
||||
const accessToken = useAuthStore((s) => s.accessToken);
|
||||
const authed = Boolean(accessToken);
|
||||
const tenantId = useTenantStore((s) => s.tenantId);
|
||||
const setPerms = useLayoutStore((s) => s.setPerms);
|
||||
|
||||
const [items, setItems] = useState<MenuNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [permissions, setPermissions] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) {
|
||||
setItems([]);
|
||||
setError(null);
|
||||
setPerms([]);
|
||||
setPermissions([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
Promise.all([
|
||||
iamMenu.nav().catch(() => [] as MenuNode[]),
|
||||
iamMenu.perms().catch(() => ({ perms: [] as string[] })),
|
||||
])
|
||||
.then(([tree, pr]) => {
|
||||
if (cancelled) return;
|
||||
const navItems = Array.isArray(tree) ? tree : [];
|
||||
const perms = Array.isArray(pr?.perms) ? pr.perms : [];
|
||||
setItems(navItems);
|
||||
setPerms(perms);
|
||||
setPermissions(perms);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setItems([]);
|
||||
setPerms([]);
|
||||
setPermissions([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accessToken, tenantId, setPerms]);
|
||||
|
||||
return { items, loading, error, authed, permissions };
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { introspectAccessToken } from '@/lib/api/auth';
|
||||
import { iamUser } from '@/lib/api/iam';
|
||||
import type { IamUser } from '@/lib/api/types/user';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useTenantStore } from '@/stores/tenant-store';
|
||||
|
||||
type UserProfileState = {
|
||||
profile: IamUser | null;
|
||||
userSub: string | null;
|
||||
loading: boolean;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function displayLabel(profile: IamUser | null, userSub: string | null, loading: boolean): string {
|
||||
if (loading) return '加载中…';
|
||||
if (profile) {
|
||||
const n = profile.real_name?.trim() || profile.user_name?.trim();
|
||||
if (n) return n;
|
||||
}
|
||||
if (userSub) return userSub.length > 12 ? `${userSub.slice(0, 10)}…` : userSub;
|
||||
return '用户';
|
||||
}
|
||||
|
||||
/** 从 UserMenu 提取的用户资料获取逻辑 */
|
||||
export function useUserProfile(): UserProfileState {
|
||||
const accessToken = useAuthStore((s) => s.accessToken);
|
||||
const [profile, setProfile] = useState<IamUser | null>(null);
|
||||
const [userSub, setUserSub] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) {
|
||||
setProfile(null);
|
||||
setUserSub(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setProfile(null);
|
||||
setUserSub(null);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const intro = await introspectAccessToken(accessToken);
|
||||
if (cancelled) return;
|
||||
if (!intro.active || !intro.sub) {
|
||||
setUserSub(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setUserSub(intro.sub);
|
||||
try {
|
||||
const u = await iamUser.get(intro.sub);
|
||||
if (!cancelled) {
|
||||
setProfile(u);
|
||||
useTenantStore.getState().hydrateFromUserTenant(u.tenant_id);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setProfile(null);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setUserSub(null);
|
||||
setProfile(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accessToken]);
|
||||
|
||||
return {
|
||||
profile,
|
||||
userSub,
|
||||
loading,
|
||||
label: displayLabel(profile, userSub, loading),
|
||||
};
|
||||
}
|
||||
|
||||
export function avatarInitials(profile: IamUser | null, userSub: string | null): string {
|
||||
const name = profile?.real_name?.trim() || profile?.user_name?.trim();
|
||||
if (name) {
|
||||
const arr = [...name];
|
||||
if (arr.length >= 2) return (arr[0] + arr[1]).toUpperCase();
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
if (userSub) return userSub.replace(/-/g, '').slice(0, 2).toUpperCase() || '?';
|
||||
return '?';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** 解析 Go 侧常见列表响应 `{ items, total, ... }` */
|
||||
export function extractListItems<T>(data: unknown): T[] {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return [];
|
||||
}
|
||||
const o = data as { items?: unknown; Items?: unknown };
|
||||
const arr = o.items ?? o.Items;
|
||||
return Array.isArray(arr) ? (arr as T[]) : [];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** 仅允许站内相对路径,防止开放重定向 */
|
||||
export function safeReturnPath(from: string | null | undefined, fallback = '/dashboard'): string {
|
||||
if (from == null || typeof from !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
const t = from.trim();
|
||||
if (!t.startsWith('/') || t.startsWith('//')) {
|
||||
return fallback;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** 供 `apiJson` 调用,避免直接依赖 React;由 AppProviders 注册实现。 */
|
||||
type AuthEventsImpl = {
|
||||
on401: (msg: string) => void;
|
||||
on403: (msg: string) => void;
|
||||
};
|
||||
|
||||
let impl: Partial<AuthEventsImpl> = {};
|
||||
let last401At = 0;
|
||||
|
||||
export function registerAuthEvents(next: Partial<AuthEventsImpl>) {
|
||||
impl = next;
|
||||
}
|
||||
|
||||
/** 同一秒内合并多次 401,避免并发请求重复弹窗 */
|
||||
export function emit401Unauthorized(msg: string) {
|
||||
const now = Date.now();
|
||||
if (now - last401At < 800) {
|
||||
return;
|
||||
}
|
||||
last401At = now;
|
||||
impl.on401?.(msg);
|
||||
}
|
||||
|
||||
export function emit403Forbidden(msg: string) {
|
||||
impl.on403?.(msg);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { getOAuthClientId, getOAuthRedirectUri, getPublicApiOrigin } from '@/lib/env';
|
||||
import { createPKCEPair } from '@/lib/oauth/pkce';
|
||||
|
||||
const VERIFIER_KEY = 'smart_oauth_pkce_verifier';
|
||||
|
||||
/** 跳转浏览器授权页(与 GET /oauth/authorize 一致);PKCE verifier 写入 sessionStorage,回调页取出换 token。 */
|
||||
export async function redirectToAuthorize(): Promise<void> {
|
||||
const { verifier, challenge } = await createPKCEPair();
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem(VERIFIER_KEY, verifier);
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: getOAuthClientId(),
|
||||
redirect_uri: getOAuthRedirectUri(),
|
||||
scope: 'openid',
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
state:
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: String(Date.now()),
|
||||
});
|
||||
const url = `${getPublicApiOrigin()}/oauth/authorize?${params.toString()}`;
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
export function takeStoredPkceVerifier(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const v = sessionStorage.getItem(VERIFIER_KEY);
|
||||
if (v) {
|
||||
sessionStorage.removeItem(VERIFIER_KEY);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/** RFC 7636:生成 code_verifier 与 S256 code_challenge(与 Go VerifyPKCES256 一致)。 */
|
||||
|
||||
function base64Url(buf: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export async function createPKCEPair(): Promise<{ verifier: string; challenge: string }> {
|
||||
const arr = new Uint8Array(32);
|
||||
crypto.getRandomValues(arr);
|
||||
const verifier = base64Url(arr.buffer);
|
||||
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
|
||||
const challenge = base64Url(hash);
|
||||
return { verifier, challenge };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
const CH = 'smart-auth';
|
||||
const LS_KEY = 'smart_logout_ping';
|
||||
|
||||
/** 当前标签页主动登出时通知其它标签 */
|
||||
export function broadcastLogout(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (typeof BroadcastChannel !== 'undefined') {
|
||||
const bc = new BroadcastChannel(CH);
|
||||
bc.postMessage({ type: 'logout', t: Date.now() });
|
||||
bc.close();
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, String(Date.now()));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export type LogoutListener = () => void;
|
||||
|
||||
/** 其它标签登出时回调(需在客户端挂载一次) */
|
||||
export function subscribeRemoteLogout(onLogout: LogoutListener): () => void {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let bc: BroadcastChannel | null = null;
|
||||
try {
|
||||
if (typeof BroadcastChannel !== 'undefined') {
|
||||
bc = new BroadcastChannel(CH);
|
||||
bc.onmessage = (ev: MessageEvent<{ type?: string }>) => {
|
||||
if (ev.data?.type === 'logout') {
|
||||
onLogout();
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
bc = null;
|
||||
}
|
||||
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === LS_KEY && e.newValue) {
|
||||
onLogout();
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', onStorage);
|
||||
bc?.close();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user