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:
24
packages/ui-components/package.json
Normal file
24
packages/ui-components/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
72
packages/ui-components/src/empty.tsx
Normal file
72
packages/ui-components/src/empty.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
packages/ui-components/src/error-boundary.tsx
Normal file
88
packages/ui-components/src/error-boundary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
packages/ui-components/src/index.ts
Normal file
38
packages/ui-components/src/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @edu/ui-components - 共享组件库
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联文档:teacher-portal 02-architecture-design.md §7
|
||||
*
|
||||
* 包含:
|
||||
* - ErrorBoundary:React 渲染异常兜底
|
||||
* - Loading:骨架屏 / 加载占位
|
||||
* - Empty:空态展示
|
||||
* - RequirePermission:L3 组件级视口控制
|
||||
* - cn:类名合并工具(project_rules §3.9)
|
||||
*
|
||||
* 后续扩展(P2+):
|
||||
* - AppShell(MF Shell 布局)
|
||||
* - shadcn/ui 基础组件(Button/Input/Select/Modal/Toast/DataTable)
|
||||
* - A11y 工具集(useA11yId/mergeA11yProps/describeInput/focus-trap)
|
||||
* - Form(react-hook-form + zodResolver)
|
||||
* - RichTextEditor(Tiptap 封装)
|
||||
* - Chart(recharts 封装)
|
||||
*/
|
||||
|
||||
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";
|
||||
65
packages/ui-components/src/loading.tsx
Normal file
65
packages/ui-components/src/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
packages/ui-components/src/require-permission.tsx
Normal file
48
packages/ui-components/src/require-permission.tsx
Normal 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 渲染 children,false 渲染 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;
|
||||
}
|
||||
83
packages/ui-components/src/utils/cn.ts
Normal file
83
packages/ui-components/src/utils/cn.ts
Normal 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;
|
||||
}
|
||||
20
packages/ui-components/tsconfig.json
Normal file
20
packages/ui-components/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user