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:
SpecialX
2026-07-10 12:58:22 +08:00
parent 2a2a56f541
commit faaaf29f67
120 changed files with 23201 additions and 2 deletions

View File

@@ -0,0 +1,62 @@
/**
* @edu/hooks - 共享 Hooks 库
*
* 维护者ai13teacher-portal
* 关联文档teacher-portal 02-architecture-design.md §8
*
* 包含:
* - useAuth会话状态user/token/login/logout/refresh
* - usePermission权限查询hasPermission/hasAny/hasAll + dataScope
* - useViewports视口列表按 scope 过滤)
* - useApiApiClient 实例(注入 token + 401 处理 + trace_id
* - useA11yId唯一 ARIA ID 生成
* - useAriaLivearia-live 区域管理
* - useToast全局 toast 通知
* - useTraceId当前会话 trace_id
*
* 设计原则:
* - hooks 不直接调 API保持纯客户端逻辑API 调用由 apps/* 注入)
* - 权限/视口数据通过 props 注入,避免 hooks 包耦合 iam
* - token 存储使用 localStorageF12 裁决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";

View File

@@ -0,0 +1,68 @@
/**
* 共享类型定义hooks 包内部使用)
*
* 维护者ai13teacher-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;
/** 自动关闭时长ms0 表示不自动关闭 */
duration?: number;
}
/**
* 鉴权状态
*/
export interface AuthState {
user: UserSession | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
}

View 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;
}

View File

@@ -0,0 +1,90 @@
import { useCallback, useMemo } from "react";
/**
* useApi - ApiClient 实例管理
*
* 用途:注入 token + 401 处理 + trace_id 透传。
* P2 阶段GraphQL clienturql/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,
};
}

View 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,
};
}

View File

@@ -0,0 +1,93 @@
import { useCallback, useEffect, useState } from "react";
import type { AuthState, UserSession } from "./types.js";
/**
* useAuth - 会话状态管理
*
* 用途管理用户登录状态、token、用户信息。
* P2 阶段token 存储在 localStorageF12 裁决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 + userF12 裁决 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,
};
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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,
};
}