docs: ai 协作文档体系重构与多 ai 仲裁结果落地
1.AI 协作文档体系重构(objections/worklines/contracts+matrix.md) 2.coord 仲裁文档(final-decisions/cross-review/final-rulings/orchestration) 3.各服务 01/02 文档补全 4.共享包初始化(shared-ts/shared-go/hooks/ui-components/ui-tokens) 5.Proto 契约补全 6.004 架构影响地图更新 7.端口分配表 8.设计规格文档
This commit is contained in:
62
packages/hooks/src/index.ts
Normal file
62
packages/hooks/src/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @edu/hooks - 共享 Hooks 库
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联文档:teacher-portal 02-architecture-design.md §8
|
||||
*
|
||||
* 包含:
|
||||
* - useAuth:会话状态(user/token/login/logout/refresh)
|
||||
* - usePermission:权限查询(hasPermission/hasAny/hasAll + dataScope)
|
||||
* - useViewports:视口列表(按 scope 过滤)
|
||||
* - useApi:ApiClient 实例(注入 token + 401 处理 + trace_id)
|
||||
* - useA11yId:唯一 ARIA ID 生成
|
||||
* - useAriaLive:aria-live 区域管理
|
||||
* - useToast:全局 toast 通知
|
||||
* - useTraceId:当前会话 trace_id
|
||||
*
|
||||
* 设计原则:
|
||||
* - hooks 不直接调 API,保持纯客户端逻辑(API 调用由 apps/* 注入)
|
||||
* - 权限/视口数据通过 props 注入,避免 hooks 包耦合 iam
|
||||
* - token 存储使用 localStorage(F12 裁决,P2 阶段)
|
||||
*/
|
||||
|
||||
export { useAuth } from "./use-auth.js";
|
||||
export type { UseAuthReturn } from "./use-auth.js";
|
||||
|
||||
export { usePermission } from "./use-permission.js";
|
||||
export type {
|
||||
UsePermissionProps,
|
||||
UsePermissionReturn,
|
||||
} from "./use-permission.js";
|
||||
|
||||
export { useViewports } from "./use-viewports.js";
|
||||
export type { UseViewportsProps, UseViewportsReturn } from "./use-viewports.js";
|
||||
|
||||
export { useApi } from "./use-api.js";
|
||||
export type { UseApiProps, UseApiReturn } from "./use-api.js";
|
||||
|
||||
export {
|
||||
useA11yId,
|
||||
useA11yIds,
|
||||
mergeA11yProps,
|
||||
describeInput,
|
||||
} from "./use-a11y-id.js";
|
||||
|
||||
export { useAriaLive } from "./use-aria-live.js";
|
||||
export type { UseAriaLiveReturn, AriaLivePoliteness } from "./use-aria-live.js";
|
||||
|
||||
export { useToast } from "./use-toast.js";
|
||||
export type { UseToastReturn } from "./use-toast.js";
|
||||
|
||||
export { useTraceId } from "./use-trace-id.js";
|
||||
export type { UseTraceIdReturn } from "./use-trace-id.js";
|
||||
|
||||
// 共享类型
|
||||
export type {
|
||||
UserSession,
|
||||
DataScope,
|
||||
PermissionContext,
|
||||
Viewport,
|
||||
ToastMessage,
|
||||
AuthState,
|
||||
} from "./types.js";
|
||||
68
packages/hooks/src/types.ts
Normal file
68
packages/hooks/src/types.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 共享类型定义(hooks 包内部使用)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
/**
|
||||
* 用户会话信息
|
||||
*/
|
||||
export interface UserSession {
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
roles: string[];
|
||||
/** 数据范围(DataScope 6 级) */
|
||||
dataScope?: DataScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据范围(对齐 iam DataScope 6 级)
|
||||
*/
|
||||
export type DataScope =
|
||||
"ALL" | "SCHOOL" | "GRADE" | "CLASS" | "SUBJECT" | "SELF";
|
||||
|
||||
/**
|
||||
* 权限点检查结果
|
||||
*/
|
||||
export interface PermissionContext {
|
||||
/** 当前用户拥有的权限点列表 */
|
||||
permissions: string[];
|
||||
/** 当前用户的数据范围 */
|
||||
dataScope: DataScope;
|
||||
/** 当前用户的角色列表 */
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 视口(Viewport)- iam 4 层视口模型
|
||||
*/
|
||||
export interface Viewport {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "admin" | "teacher" | "student" | "parent";
|
||||
scope: DataScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知消息(Toast)
|
||||
*/
|
||||
export interface ToastMessage {
|
||||
id: string;
|
||||
type: "success" | "error" | "warning" | "info";
|
||||
title: string;
|
||||
description?: string;
|
||||
/** 自动关闭时长(ms),0 表示不自动关闭 */
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 鉴权状态
|
||||
*/
|
||||
export interface AuthState {
|
||||
user: UserSession | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
119
packages/hooks/src/use-a11y-id.ts
Normal file
119
packages/hooks/src/use-a11y-id.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
/**
|
||||
* useA11yId - 唯一 ARIA ID 生成器
|
||||
*
|
||||
* 用途:为表单元素生成 aria-labelledby / aria-describedby 关联的唯一 ID。
|
||||
* 迁移自 CICD 项目 A11y 工具集。
|
||||
*
|
||||
* @example
|
||||
* const id = useA11yId("email-input");
|
||||
* <input id={id} aria-describedby={`${id}-error`} />
|
||||
* <span id={`${id}-error`}>{errorMessage}</span>
|
||||
*/
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
/**
|
||||
* 生成唯一 ID(带可选前缀)。
|
||||
* 使用计数器 + 随机数,避免 SSR/CSR hydration mismatch。
|
||||
*/
|
||||
export function useA11yId(prefix?: string): string {
|
||||
const [id] = useState(() => {
|
||||
idCounter += 1;
|
||||
const random = Math.random().toString(36).slice(2, 8);
|
||||
const base = prefix
|
||||
? `${prefix}-${idCounter}-${random}`
|
||||
: `a11y-${idCounter}-${random}`;
|
||||
return base;
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量生成关联 ID(用于表单 input + label + error + description 关联)
|
||||
*
|
||||
* @example
|
||||
* const ids = useA11yIds("email");
|
||||
* // ids = { input: "email-input-1-xxx", label: "email-label-1-xxx", error: "email-error-1-xxx", description: "email-description-1-xxx" }
|
||||
*/
|
||||
export function useA11yIds(prefix: string): {
|
||||
input: string;
|
||||
label: string;
|
||||
error: string;
|
||||
description: string;
|
||||
} {
|
||||
const inputId = useA11yId(`${prefix}-input`);
|
||||
const labelId = useA11yId(`${prefix}-label`);
|
||||
const errorId = useA11yId(`${prefix}-error`);
|
||||
const descriptionId = useA11yId(`${prefix}-description`);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
input: inputId,
|
||||
label: labelId,
|
||||
error: errorId,
|
||||
description: descriptionId,
|
||||
}),
|
||||
[inputId, labelId, errorId, descriptionId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并 ARIA 属性(覆盖优先级:后者覆盖前者)
|
||||
*
|
||||
* @example
|
||||
* mergeA11yProps({ "aria-label": "默认" }, { "aria-label": "自定义" })
|
||||
* // => { "aria-label": "自定义" }
|
||||
*/
|
||||
export function mergeA11yProps(
|
||||
...props: Array<Record<string, unknown> | undefined>
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const prop of props) {
|
||||
if (prop) {
|
||||
Object.assign(result, prop);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 描述输入框的 ARIA 属性
|
||||
*
|
||||
* @example
|
||||
* const a11y = describeInput({ label: "邮箱", required: true, error: "邮箱格式错误", description: "请输入工作邮箱" });
|
||||
* <input {...a11y} />
|
||||
*/
|
||||
export function describeInput(options: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
description?: string;
|
||||
invalid?: boolean;
|
||||
}): Record<string, unknown> {
|
||||
const { label, required, error, description, invalid } = options;
|
||||
const props: Record<string, unknown> = {
|
||||
"aria-label": label,
|
||||
};
|
||||
|
||||
if (required) {
|
||||
props["aria-required"] = true;
|
||||
}
|
||||
|
||||
if (invalid || error) {
|
||||
props["aria-invalid"] = true;
|
||||
}
|
||||
|
||||
// aria-describedby 由调用方拼接(需关联 error/description 的 ID)
|
||||
const describedBy: string[] = [];
|
||||
if (description) describedBy.push("description");
|
||||
if (error) describedBy.push("error");
|
||||
|
||||
if (describedBy.length > 0) {
|
||||
props["aria-describedby"] = describedBy.join(" ");
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
90
packages/hooks/src/use-api.ts
Normal file
90
packages/hooks/src/use-api.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
/**
|
||||
* useApi - ApiClient 实例管理
|
||||
*
|
||||
* 用途:注入 token + 401 处理 + trace_id 透传。
|
||||
* P2 阶段:GraphQL client(urql/apollo)注入;本 hook 提供统一 fetcher 封装。
|
||||
*
|
||||
* 注意:本 hook 不直接创建 GraphQL client(由 Shell 的 GraphQLProvider 注入)。
|
||||
* 本 hook 提供"API 调用层"封装,用于非 GraphQL 场景(如文件上传 / SSE 流)。
|
||||
*
|
||||
* @example
|
||||
* const { fetchJson, fetchWithAuth } = useApi({ token, traceId });
|
||||
* const data = await fetchJson("/api/v1/upload", { method: "POST", body: formData });
|
||||
*/
|
||||
|
||||
export interface UseApiProps {
|
||||
/** 认证 token(从 useAuth 获取) */
|
||||
token: string | null;
|
||||
/** trace_id(从 useTraceId 获取) */
|
||||
traceId?: string | null;
|
||||
/** 401 回调(通常跳转登录页) */
|
||||
onUnauthorized?: () => void;
|
||||
}
|
||||
|
||||
export interface UseApiReturn {
|
||||
/** 带 auth + trace_id 的 fetch 封装 */
|
||||
fetchWithAuth: (url: string, init?: RequestInit) => Promise<Response>;
|
||||
/** fetchJson - 解析 JSON 响应 */
|
||||
fetchJson: <T>(url: string, init?: RequestInit) => Promise<T>;
|
||||
/** 构造带 auth 的 headers */
|
||||
authHeaders: Record<string, string>;
|
||||
}
|
||||
|
||||
export function useApi({
|
||||
token,
|
||||
traceId,
|
||||
onUnauthorized,
|
||||
}: UseApiProps): UseApiReturn {
|
||||
const authHeaders = useMemo(() => {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
if (traceId) {
|
||||
headers["X-Trace-Id"] = traceId;
|
||||
}
|
||||
return headers;
|
||||
}, [token, traceId]);
|
||||
|
||||
const fetchWithAuth = useCallback(
|
||||
async (url: string, init?: RequestInit): Promise<Response> => {
|
||||
const mergedInit: RequestInit = {
|
||||
...init,
|
||||
headers: {
|
||||
...authHeaders,
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(url, mergedInit);
|
||||
|
||||
if (response.status === 401) {
|
||||
onUnauthorized?.();
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
[authHeaders, onUnauthorized],
|
||||
);
|
||||
|
||||
const fetchJson = useCallback(
|
||||
async <T>(url: string, init?: RequestInit): Promise<T> => {
|
||||
const response = await fetchWithAuth(url, init);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
},
|
||||
[fetchWithAuth],
|
||||
);
|
||||
|
||||
return {
|
||||
fetchWithAuth,
|
||||
fetchJson,
|
||||
authHeaders,
|
||||
};
|
||||
}
|
||||
52
packages/hooks/src/use-aria-live.ts
Normal file
52
packages/hooks/src/use-aria-live.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* useAriaLive - aria-live 区域管理
|
||||
*
|
||||
* 用途:动态向 aria-live 区域推送消息,供屏幕阅读器播报。
|
||||
* 迁移自 CICD 项目 A11y 工具集。
|
||||
*
|
||||
* @example
|
||||
* const { message, announce } = useAriaLive();
|
||||
* announce("表单提交成功", "polite");
|
||||
* // 渲染:<div aria-live="polite">{message}</div>
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export type AriaLivePoliteness = "polite" | "assertive" | "off";
|
||||
|
||||
export interface UseAriaLiveReturn {
|
||||
/** 当前消息内容 */
|
||||
message: string;
|
||||
/** 当前 politeness 级别 */
|
||||
politeness: AriaLivePoliteness;
|
||||
/** 推送消息 */
|
||||
announce: (message: string, politeness?: AriaLivePoliteness) => void;
|
||||
/** 清除消息 */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export function useAriaLive(): UseAriaLiveReturn {
|
||||
const [message, setMessage] = useState("");
|
||||
const [politeness, setPoliteness] = useState<AriaLivePoliteness>("polite");
|
||||
|
||||
const announce = useCallback(
|
||||
(msg: string, level: AriaLivePoliteness = "polite"): void => {
|
||||
setPoliteness(level);
|
||||
// 先清空再设置,确保相同消息也能触发播报
|
||||
setMessage("");
|
||||
requestAnimationFrame(() => setMessage(msg));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clear = useCallback((): void => {
|
||||
setMessage("");
|
||||
}, []);
|
||||
|
||||
return {
|
||||
message,
|
||||
politeness,
|
||||
announce,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
93
packages/hooks/src/use-auth.ts
Normal file
93
packages/hooks/src/use-auth.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { AuthState, UserSession } from "./types.js";
|
||||
|
||||
/**
|
||||
* useAuth - 会话状态管理
|
||||
*
|
||||
* 用途:管理用户登录状态、token、用户信息。
|
||||
* P2 阶段:token 存储在 localStorage(F12 裁决),P6 评估迁移 httpOnly Cookie。
|
||||
*
|
||||
* 注意:本 hook 提供"状态管理"骨架,具体的登录/登出 API 调用
|
||||
* 由 apps/* 注入(通过 AuthProvider Context 或 props 传入 fetcher)。
|
||||
*
|
||||
* @example
|
||||
* const { user, isAuthenticated, login, logout } = useAuth();
|
||||
*/
|
||||
|
||||
const TOKEN_KEY = "edu_auth_token";
|
||||
const USER_KEY = "edu_auth_user";
|
||||
|
||||
export interface UseAuthReturn extends AuthState {
|
||||
/** 登录(由调用方注入 fetcher) */
|
||||
login: (token: string, user: UserSession) => void;
|
||||
/** 登出 */
|
||||
logout: () => void;
|
||||
/** 刷新用户信息 */
|
||||
refreshUser: (user: UserSession) => void;
|
||||
}
|
||||
|
||||
export function useAuth(): UseAuthReturn {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
// 初始化:从 localStorage 读取 token + user(F12 裁决 P2 用 localStorage)
|
||||
useEffect(() => {
|
||||
try {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
const userJson = localStorage.getItem(USER_KEY);
|
||||
const user = userJson ? (JSON.parse(userJson) as UserSession) : null;
|
||||
|
||||
setState({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: Boolean(token && user),
|
||||
isLoading: false,
|
||||
});
|
||||
} catch {
|
||||
setState({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = useCallback((token: string, user: UserSession): void => {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
setState({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const logout = useCallback((): void => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setState({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refreshUser = useCallback((user: UserSession): void => {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
setState((prev) => ({ ...prev, user }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
login,
|
||||
logout,
|
||||
refreshUser,
|
||||
};
|
||||
}
|
||||
76
packages/hooks/src/use-permission.ts
Normal file
76
packages/hooks/src/use-permission.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { PermissionContext } from "./types.js";
|
||||
|
||||
/**
|
||||
* usePermission - 权限查询 Hook
|
||||
*
|
||||
* 用途:L2 路由级 + L3 组件级视口控制。
|
||||
* 权限点命名遵循 F7 裁决:`<RESOURCE>_<ACTION>`(如 `EXAM_READ`)。
|
||||
* 数据范围用后缀 `_OWN`/`_CHILD`(如 `GRADE_READ_CHILD`)。
|
||||
*
|
||||
* 注意:权限数据由调用方注入(apps/* 从 iam /iam/permissions/effective 获取后传入)。
|
||||
* 本 hook 不直接调 API,保持纯客户端逻辑。
|
||||
*
|
||||
* @example
|
||||
* const { hasPermission, hasAny, hasAll } = usePermission({ permissions, dataScope, roles });
|
||||
*
|
||||
* if (hasPermission("EXAM_CREATE")) { ... }
|
||||
* if (hasPermission("GRADE_READ_CHILD")) { ... } // 数据范围后缀
|
||||
*/
|
||||
|
||||
export interface UsePermissionProps {
|
||||
/** 权限上下文(从 iam /iam/permissions/effective 获取) */
|
||||
context: PermissionContext | null;
|
||||
}
|
||||
|
||||
export interface UsePermissionReturn {
|
||||
/** 检查是否拥有指定权限点 */
|
||||
hasPermission: (perm: string) => boolean;
|
||||
/** 检查是否拥有任一权限点 */
|
||||
hasAny: (...perms: string[]) => boolean;
|
||||
/** 检查是否拥有全部权限点 */
|
||||
hasAll: (...perms: string[]) => boolean;
|
||||
/** 检查是否拥有指定角色 */
|
||||
hasRole: (role: string) => boolean;
|
||||
/** 当前数据范围 */
|
||||
dataScope: PermissionContext["dataScope"] | null;
|
||||
}
|
||||
|
||||
export function usePermission({
|
||||
context,
|
||||
}: UsePermissionProps): UsePermissionReturn {
|
||||
const permissions = context?.permissions ?? [];
|
||||
const roles = context?.roles ?? [];
|
||||
const dataScope = context?.dataScope ?? null;
|
||||
|
||||
const permissionSet = useMemo(() => new Set(permissions), [permissions]);
|
||||
const roleSet = useMemo(() => new Set(roles), [roles]);
|
||||
|
||||
const hasPermission = useCallback(
|
||||
(perm: string): boolean => permissionSet.has(perm),
|
||||
[permissionSet],
|
||||
);
|
||||
|
||||
const hasAny = useCallback(
|
||||
(...perms: string[]): boolean => perms.some((p) => permissionSet.has(p)),
|
||||
[permissionSet],
|
||||
);
|
||||
|
||||
const hasAll = useCallback(
|
||||
(...perms: string[]): boolean => perms.every((p) => permissionSet.has(p)),
|
||||
[permissionSet],
|
||||
);
|
||||
|
||||
const hasRole = useCallback(
|
||||
(role: string): boolean => roleSet.has(role),
|
||||
[roleSet],
|
||||
);
|
||||
|
||||
return {
|
||||
hasPermission,
|
||||
hasAny,
|
||||
hasAll,
|
||||
hasRole,
|
||||
dataScope,
|
||||
};
|
||||
}
|
||||
66
packages/hooks/src/use-toast.ts
Normal file
66
packages/hooks/src/use-toast.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { ToastMessage } from "./types.js";
|
||||
|
||||
/**
|
||||
* useToast - 全局 Toast 通知管理
|
||||
*
|
||||
* 用途:成功/错误/警告/信息提示,自动关闭。
|
||||
* 与 Zustand ui-store 解耦,提供 hook 层 API。
|
||||
*
|
||||
* @example
|
||||
* const { toasts, showToast, dismissToast } = useToast();
|
||||
* showToast({ type: "success", title: "保存成功" });
|
||||
*/
|
||||
|
||||
export interface UseToastReturn {
|
||||
/** 当前活跃的 toast 列表 */
|
||||
toasts: ToastMessage[];
|
||||
/** 展示 toast */
|
||||
showToast: (toast: Omit<ToastMessage, "id">) => string;
|
||||
/** 关闭指定 toast */
|
||||
dismissToast: (id: string) => void;
|
||||
/** 关闭全部 toast */
|
||||
dismissAll: () => void;
|
||||
}
|
||||
|
||||
export function useToast(): UseToastReturn {
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||
|
||||
const dismissToast = useCallback((id: string): void => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback(
|
||||
(toast: Omit<ToastMessage, "id">): string => {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const fullToast: ToastMessage = {
|
||||
id,
|
||||
duration: 4000,
|
||||
...toast,
|
||||
};
|
||||
|
||||
setToasts((prev) => [...prev, fullToast]);
|
||||
|
||||
// 自动关闭
|
||||
if (fullToast.duration && fullToast.duration > 0) {
|
||||
setTimeout(() => {
|
||||
dismissToast(id);
|
||||
}, fullToast.duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
[dismissToast],
|
||||
);
|
||||
|
||||
const dismissAll = useCallback((): void => {
|
||||
setToasts([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
toasts,
|
||||
showToast,
|
||||
dismissToast,
|
||||
dismissAll,
|
||||
};
|
||||
}
|
||||
61
packages/hooks/src/use-trace-id.ts
Normal file
61
packages/hooks/src/use-trace-id.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
/**
|
||||
* useTraceId - 当前会话 trace_id 管理
|
||||
*
|
||||
* 用途:OpenTelemetry traceparent 透传,用于日志关联 + 链路追踪。
|
||||
* ai13 新增(对齐 project_rules §12 可观测性规范)。
|
||||
*
|
||||
* @example
|
||||
* const { traceId, setTraceId, withTraceId } = useTraceId();
|
||||
* setTraceId("abc123");
|
||||
* withTraceId(() => fetch("/api/v1/exams", { headers: { "X-Trace-Id": traceId } }));
|
||||
*/
|
||||
|
||||
export interface UseTraceIdReturn {
|
||||
/** 当前 trace_id */
|
||||
traceId: string | null;
|
||||
/** 设置 trace_id(从响应头 X-Trace-Id 获取) */
|
||||
setTraceId: (id: string) => void;
|
||||
/** 清除 trace_id */
|
||||
clear: () => void;
|
||||
/**
|
||||
* 在 trace_id 上下文中执行回调(自动注入 header)
|
||||
* 用于 fetch 包装
|
||||
*/
|
||||
withTraceId: <T>(
|
||||
fn: (headers: Record<string, string>) => Promise<T>,
|
||||
) => Promise<T>;
|
||||
}
|
||||
|
||||
export function useTraceId(): UseTraceIdReturn {
|
||||
const [traceId, setTraceIdState] = useState<string | null>(null);
|
||||
|
||||
const setTraceId = useCallback((id: string): void => {
|
||||
setTraceIdState(id);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback((): void => {
|
||||
setTraceIdState(null);
|
||||
}, []);
|
||||
|
||||
const withTraceId = useCallback(
|
||||
async <T>(
|
||||
fn: (headers: Record<string, string>) => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (traceId) {
|
||||
headers["X-Trace-Id"] = traceId;
|
||||
}
|
||||
return fn(headers);
|
||||
},
|
||||
[traceId],
|
||||
);
|
||||
|
||||
return {
|
||||
traceId,
|
||||
setTraceId,
|
||||
clear,
|
||||
withTraceId,
|
||||
};
|
||||
}
|
||||
65
packages/hooks/src/use-viewports.ts
Normal file
65
packages/hooks/src/use-viewports.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Viewport } from "./types.js";
|
||||
|
||||
/**
|
||||
* useViewports - 视口列表查询
|
||||
*
|
||||
* 用途:按 scope 过滤当前用户的可用视口(iam 4 层视口模型)。
|
||||
* 视口数据由调用方注入(apps/* 从 iam /iam/viewports 获取后传入)。
|
||||
*
|
||||
* iam 视口 4 层:admin / teacher / student / parent
|
||||
*
|
||||
* @example
|
||||
* const { viewports, getByType, hasViewport } = useViewports({ viewports });
|
||||
* const teacherViewports = getByType("teacher");
|
||||
*/
|
||||
|
||||
export interface UseViewportsProps {
|
||||
/** 当前用户的视口列表(从 iam 获取) */
|
||||
viewports: Viewport[] | null;
|
||||
}
|
||||
|
||||
export interface UseViewportsReturn {
|
||||
/** 全部视口 */
|
||||
viewports: Viewport[];
|
||||
/** 按类型过滤 */
|
||||
getByType: (type: Viewport["type"]) => Viewport[];
|
||||
/** 检查是否拥有指定类型视口 */
|
||||
hasViewport: (type: Viewport["type"]) => boolean;
|
||||
/** 按 ID 查找 */
|
||||
getById: (id: string) => Viewport | undefined;
|
||||
}
|
||||
|
||||
export function useViewports({
|
||||
viewports: input,
|
||||
}: UseViewportsProps): UseViewportsReturn {
|
||||
const viewports = useMemo(() => input ?? [], [input]);
|
||||
|
||||
const getByType = useMemo(
|
||||
() =>
|
||||
(type: Viewport["type"]): Viewport[] =>
|
||||
viewports.filter((v) => v.type === type),
|
||||
[viewports],
|
||||
);
|
||||
|
||||
const hasViewport = useMemo(
|
||||
() =>
|
||||
(type: Viewport["type"]): boolean =>
|
||||
viewports.some((v) => v.type === type),
|
||||
[viewports],
|
||||
);
|
||||
|
||||
const getById = useMemo(
|
||||
() =>
|
||||
(id: string): Viewport | undefined =>
|
||||
viewports.find((v) => v.id === id),
|
||||
[viewports],
|
||||
);
|
||||
|
||||
return {
|
||||
viewports,
|
||||
getByType,
|
||||
hasViewport,
|
||||
getById,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user