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;
};
+30
View File
@@ -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;
}
+60
View File
@@ -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 };
}
+129
View File
@@ -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,
};
}
+14
View File
@@ -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);
};
}
+70
View File
@@ -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 };
}
+97
View File
@@ -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 '?';
}
+9
View File
@@ -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[]) : [];
}
+11
View File
@@ -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;
}
+26
View File
@@ -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);
}
+39
View File
@@ -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;
}
+19
View File
@@ -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 };
}
+58
View File
@@ -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();
};
}