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,24 @@
{
"name": "@edu/ui-components",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"lint": "eslint src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@edu/ui-tokens": "workspace:*"
},
"peerDependencies": {
"react": "^18.3.0",
"react-dom": "^18.3.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"typescript": "^5.6.0"
}
}

View File

@@ -0,0 +1,72 @@
import type { ReactNode } from "react";
import { cn } from "./utils/cn.js";
/**
* Empty - 空态展示(插画占位 + 文案 + CTA
*
* 用途:数据列表为空时展示友好提示,引导用户操作。
*
* @example
* <Empty title="暂无班级" description="点击下方按钮创建第一个班级" action={<Button>新建班级</Button>} />
*/
export interface EmptyProps {
/** 标题(必填) */
title: string;
/** 描述文案 */
description?: string;
/** 行动按钮CTA */
action?: ReactNode;
/** 自定义插画;未提供时使用默认占位 */
illustration?: ReactNode;
/** 自定义类名 */
className?: string;
}
export function Empty({
title,
description,
action,
illustration,
className,
}: EmptyProps): ReactNode {
return (
<div
role="status"
className={cn(
"flex flex-col items-center justify-center gap-4 p-8 text-center",
className,
)}
>
{illustration ?? <DefaultIllustration />}
<div className="flex flex-col gap-1">
<h3 className="text-base font-medium">{title}</h3>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
{action ?? null}
</div>
);
}
function DefaultIllustration(): ReactNode {
return (
<div
className="flex h-16 w-16 items-center justify-center rounded-full bg-muted"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="h-8 w-8 text-muted-foreground"
>
<path d="M9 17a2 2 0 002 2h2a2 2 0 002-2M5 9h14l-1 8H6L5 9z" />
<path d="M9 9V5a3 3 0 016 0v4" />
</svg>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
/**
* ErrorBoundary - React 渲染异常兜底fallback UI
*
* 用途:捕获子组件树渲染异常,展示降级 UI避免整页白屏。
*
* @example
* <ErrorBoundary fallback={<ErrorFallback />}>
* <Dashboard />
* </ErrorBoundary>
*/
export interface ErrorBoundaryProps {
children: ReactNode;
/** 自定义降级 UI未提供时使用默认 fallback */
fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
/** 错误回调(上报 / 日志) */
onError?: (error: Error, info: ErrorInfo) => void;
/** 重置回调(用于外部触发重试) */
onReset?: () => void;
}
export interface ErrorBoundaryState {
error: Error | null;
}
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
this.props.onError?.(error, info);
}
reset = (): void => {
this.props.onReset?.();
this.setState({ error: null });
};
render(): ReactNode {
const { error } = this.state;
const { children, fallback } = this.props;
if (error) {
if (typeof fallback === "function") {
return fallback(error, this.reset);
}
if (fallback !== undefined) {
return fallback;
}
return <DefaultErrorFallback error={error} onReset={this.reset} />;
}
return children;
}
}
function DefaultErrorFallback({
error,
onReset,
}: {
error: Error;
onReset: () => void;
}): ReactNode {
return (
<div
role="alert"
className="flex min-h-[200px] flex-col items-center justify-center gap-4 p-8"
>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground">{error.message}</p>
<button
type="button"
onClick={onReset}
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
>
</button>
</div>
);
}

View File

@@ -0,0 +1,38 @@
/**
* @edu/ui-components - 共享组件库
*
* 维护者ai13teacher-portal
* 关联文档teacher-portal 02-architecture-design.md §7
*
* 包含:
* - ErrorBoundaryReact 渲染异常兜底
* - Loading骨架屏 / 加载占位
* - Empty空态展示
* - RequirePermissionL3 组件级视口控制
* - cn类名合并工具project_rules §3.9
*
* 后续扩展P2+
* - AppShellMF Shell 布局)
* - shadcn/ui 基础组件Button/Input/Select/Modal/Toast/DataTable
* - A11y 工具集useA11yId/mergeA11yProps/describeInput/focus-trap
* - Formreact-hook-form + zodResolver
* - RichTextEditorTiptap 封装)
* - Chartrecharts 封装)
*/
export { ErrorBoundary } from "./error-boundary.js";
export type {
ErrorBoundaryProps,
ErrorBoundaryState,
} from "./error-boundary.js";
export { Loading } from "./loading.js";
export type { LoadingProps } from "./loading.js";
export { Empty } from "./empty.js";
export type { EmptyProps } from "./empty.js";
export { RequirePermission } from "./require-permission.js";
export type { RequirePermissionProps } from "./require-permission.js";
export { cn } from "./utils/cn.js";

View File

@@ -0,0 +1,65 @@
import type { ReactNode } from "react";
import { cn } from "./utils/cn.js";
/**
* Loading - 骨架屏 / 加载占位
*
* 用途:数据加载期间展示骨架屏,避免内容跳动。
*
* @example
* <Loading /> // 默认 3 行骨架
* <Loading lines={5} /> // 5 行骨架
* <Loading variant="spinner" /> // 旋转图标
*/
export interface LoadingProps {
/** 骨架行数variant="skeleton" 时生效) */
lines?: number;
/** 展示形态 */
variant?: "skeleton" | "spinner";
/** 自定义类名 */
className?: string;
/** aria-label无障碍 */
ariaLabel?: string;
}
export function Loading({
lines = 3,
variant = "skeleton",
className,
ariaLabel = "加载中",
}: LoadingProps): ReactNode {
if (variant === "spinner") {
return (
<div
role="status"
aria-label={ariaLabel}
className={cn("flex items-center justify-center p-8", className)}
>
<span
className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary"
aria-hidden="true"
/>
<span className="sr-only">{ariaLabel}</span>
</div>
);
}
return (
<div
role="status"
aria-label={ariaLabel}
className={cn("flex flex-col gap-2 p-4", className)}
>
{Array.from({ length: lines }, (_, i) => (
<div
key={i}
className="h-4 animate-pulse rounded bg-muted"
style={{ width: `${100 - i * 10}%` }}
aria-hidden="true"
/>
))}
<span className="sr-only">{ariaLabel}</span>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import type { ReactNode } from "react";
/**
* RequirePermission - L3 组件级视口控制
*
* 用途:根据当前用户权限点决定是否渲染 children。
* 无权限时不渲染 children返回 null 或自定义 fallback
*
* 权限点命名遵循 F7 裁决:`<RESOURCE>_<ACTION>`(如 `EXAM_READ`、`GRADE_READ_CHILD`)。
* 数据范围用后缀 `_OWN`/`_CHILD`(如 `GRADE_READ_CHILD`)。
*
* 注意:本组件依赖 `@edu/hooks` 的 `usePermission()`。为避免循环依赖,
* 权限检查函数通过 props 注入由调用方apps/*)从 usePermission() 获取后传入。
*
* @example
* import { usePermission } from "@edu/hooks";
* const { hasPermission } = usePermission();
*
* <RequirePermission perm="EXAM_CREATE" hasPermission={hasPermission}>
* <Button>新建考试</Button>
* </RequirePermission>
*/
export interface RequirePermissionProps {
/** 权限点F7 规范:<RESOURCE>_<ACTION> */
perm: string;
/**
* 权限检查函数(由 @edu/hooks 的 usePermission().hasPermission 注入)
* 返回 true 渲染 childrenfalse 渲染 fallback。
*/
hasPermission: (perm: string) => boolean;
/** 子节点(有权限时渲染) */
children: ReactNode;
/** 无权限时的降级 UI未提供时返回 null */
fallback?: ReactNode;
}
export function RequirePermission({
perm,
hasPermission,
children,
fallback = null,
}: RequirePermissionProps): ReactNode {
if (!hasPermission(perm)) {
return fallback;
}
return children;
}

View File

@@ -0,0 +1,83 @@
/**
* 类名合并工具project_rules §3.9 强制使用)
*
* 用于管理条件类名,禁止字符串拼接动态类名(如 `bg-${color}-500`)。
* 基于 clsx + tailwind-merge 的轻量实现,后续接入 shadcn/ui 时替换为官方 cn()。
*/
type ClassValue =
| string
| number
| null
| false
| undefined
| ClassValue[]
| { [key: string]: unknown };
/**
* 合并类名,过滤 falsy 值。
*
* @example
* cn("px-2 py-1", isActive && "bg-primary", { "text-muted": isDisabled })
*/
export function cn(...inputs: ClassValue[]): string {
const classes: string[] = [];
for (const input of inputs) {
if (!input) continue;
if (typeof input === "string" || typeof input === "number") {
classes.push(String(input));
continue;
}
if (Array.isArray(input)) {
const nested = cn(...input);
if (nested) classes.push(nested);
continue;
}
if (typeof input === "object") {
for (const [key, value] of Object.entries(input)) {
if (value) classes.push(key);
}
}
}
// 去重 + 合并 Tailwind 冲突类(基础实现,后续替换为 tailwind-merge
return dedupeTailwindClasses(classes.join(" "));
}
/**
* 基础 Tailwind 类去重(同一前缀后者覆盖前者)。
* 完整实现待引入 tailwind-merge。
*/
function dedupeTailwindClasses(className: string): string {
const seen = new Set<string>();
const tokens = className.split(/\s+/).filter(Boolean);
// 反向遍历,保留后出现的同类令牌
for (let i = tokens.length - 1; i >= 0; i--) {
const token = tokens[i];
if (!token) continue;
const prefix = getTailwindPrefix(token);
const key = prefix ? `__${prefix}` : token;
if (seen.has(key)) continue;
seen.add(key);
}
// 恢复原始顺序
return tokens
.filter((t) => t && (seen.has(t) || seen.has(`__${getTailwindPrefix(t)}`)))
.join(" ");
}
function getTailwindPrefix(token: string): string | null {
// 匹配 Tailwind 前缀bg- text- p- m- w- h- border- 等
const match = token.match(
/^(bg|text|p|m|px|py|mx|my|w|h|min-h|min-w|border|rounded|shadow|font|leading|tracking|gap|space|flex|grid|col|row|inset|top|right|bottom|left|z|opacity|transition|duration|delay|animate|hover|focus|sm|md|lg|xl|2xl)-/,
);
return match ? match[1] : null;
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}