docs(admin-portal): 新增 nextstep-v2.md 记录下游核查结果

v1 声称完成的下游工作经核查实际未完成:
- api-gateway: /api/admin/graphql 路由未注册,go vet 编译失败
- teacher-bff: resolver 已完成但 schema 未同步(命名空间 vs 扁平)
- iam: proto 缺 BatchGetUsers rpc 声明

v2 记录详细核查证据和修复要求
This commit is contained in:
SpecialX
2026-07-14 08:26:27 +08:00
parent 99580fa13a
commit 0b42302a64
42 changed files with 4804 additions and 637 deletions

View File

@@ -0,0 +1,134 @@
import type { ReactNode } from "react";
import { cn } from "./utils/cn.js";
import { Loading } from "./loading.js";
import { Empty } from "./empty.js";
/**
* DataTable - 通用数据表格
*
* 用途:列表数据的标准展示容器,内置加载态、空态、行交互。
* 复用 Loading / Empty 组件保持一致的降级 UI。
*
* @example
* const columns: Column<User>[] = [
* { key: "name", header: "姓名" },
* { key: "score", header: "分数", align: "right", render: (u) => u.score },
* ];
* <DataTable columns={columns} data={users} loading={isLoading} rowKey={(u) => u.id} />
*/
/** 列对齐方式 */
export type ColumnAlign = "left" | "center" | "right";
/** 列定义 */
export interface Column<T> {
/** 唯一标识(用于 key */
key: string;
/** 表头内容 */
header: ReactNode;
/** 自定义单元格渲染;未提供时取 row[key] */
render?: (row: T) => ReactNode;
/** 自定义单元格类名 */
className?: string;
/** 对齐方式 */
align?: ColumnAlign;
/** 列宽CSS 值,如 "200px" / "20%" */
width?: string;
}
export interface DataTableProps<T> {
/** 列定义 */
columns: Column<T>[];
/** 数据数组 */
data: T[];
/** 加载态;为 true 时显示 Loading */
loading?: boolean;
/** 空态自定义内容;未提供时使用默认 Empty */
empty?: ReactNode;
/** 行点击回调 */
onRowClick?: (row: T) => void;
/** 行 key 生成函数;未提供时使用索引 */
rowKey?: (row: T) => string;
/** 自定义类名 */
className?: string;
}
const ALIGN_CLASS: Record<ColumnAlign, string> = {
left: "text-left",
center: "text-center",
right: "text-right",
};
export function DataTable<T>({
columns,
data,
loading,
empty,
onRowClick,
rowKey,
className,
}: DataTableProps<T>): ReactNode {
if (loading) {
return <Loading lines={Math.max(columns.length, 3)} />;
}
if (data.length === 0) {
return empty ?? <Empty title="暂无数据" description="调整筛选条件后重试" />;
}
return (
<div
className={cn(
"overflow-x-auto border border-rule rounded-card",
className,
)}
>
<table className="w-full text-sm">
<thead className="bg-subtle">
<tr className="border-b border-rule">
{columns.map((col) => (
<th
key={col.key}
style={col.width ? { width: col.width } : undefined}
className={cn(
"py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted font-medium",
col.align ? ALIGN_CLASS[col.align] : "text-left",
)}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, index) => {
const key = rowKey ? rowKey(row) : String(index);
return (
<tr
key={key}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={cn(
"border-b border-rule",
onRowClick && "cursor-pointer hover:bg-subtle",
)}
>
{columns.map((col) => (
<td
key={col.key}
className={cn(
"py-2 px-3 text-ink",
col.align ? ALIGN_CLASS[col.align] : "text-left",
col.className,
)}
>
{col.render ? col.render(row) : null}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import type { ReactNode } from "react";
import { cn } from "./utils/cn.js";
/**
* FilterBar - 通用筛选栏
*
* 用途包裹一组筛选项Select / Input 等),提供重置与应用按钮。
* 横向 flex-wrap 布局,按钮固定在右侧。
*
* @example
* <FilterBar onReset={handleReset} onApply={handleApply}>
* <select>...</select>
* <input type="text" />
* </FilterBar>
*/
export interface FilterBarProps {
/** 筛选项Select / Input 等),由调用方传入 */
children: ReactNode;
/** 重置回调 */
onReset?: () => void;
/** 应用回调 */
onApply?: () => void;
/** 自定义类名 */
className?: string;
}
export function FilterBar({
children,
onReset,
onApply,
className,
}: FilterBarProps): ReactNode {
const hasAction = onReset !== undefined || onApply !== undefined;
return (
<div className={cn("flex flex-wrap items-center gap-3", className)}>
<div className="flex flex-1 flex-wrap items-center gap-3">{children}</div>
{hasAction && (
<div className="flex items-center gap-3">
{onReset && (
<button
type="button"
onClick={onReset}
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
>
</button>
)}
{onApply && (
<button
type="button"
onClick={onApply}
className="px-4 py-1.5 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
>
</button>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,286 @@
"use client";
import { type ReactNode, type FormEvent, type ChangeEvent } from "react";
import { cn } from "./utils/cn.js";
/**
* Form - 轻量级表单(不依赖 react-hook-form
*
* 用途:简单表单场景,通过 FormData 收集所有命名字段的值。
* 复杂表单(校验 / 联动)仍推荐 react-hook-form + zodResolver。
*
* @example
* <Form onSubmit={(data) => console.log(data)}>
* <FormField label="姓名" name="name" required>
* <FormInput name="name" />
* </FormField>
* <SubmitButton>提交</SubmitButton>
* </Form>
*/
// ============ Form 容器 ============
export interface FormProps {
/** 提交回调data 为所有命名字段的值 */
onSubmit: (data: Record<string, string>) => void;
children: ReactNode;
className?: string;
}
export function Form({ onSubmit, children, className }: FormProps): ReactNode {
const handleSubmit = (e: FormEvent<HTMLFormElement>): void => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const data: Record<string, string> = {};
formData.forEach((value, key) => {
data[key] = String(value);
});
onSubmit(data);
};
return (
<form onSubmit={handleSubmit} className={cn("space-y-4", className)}>
{children}
</form>
);
}
// ============ FormField 字段容器 ============
export interface FormFieldProps {
label: ReactNode;
name: string;
required?: boolean;
error?: string;
children: ReactNode;
className?: string;
}
export function FormField({
label,
name,
required,
error,
children,
className,
}: FormFieldProps): ReactNode {
return (
<div className={className}>
<label
htmlFor={name}
className="mb-1 block text-tiny uppercase tracking-wide text-ink-muted"
>
{label}
{required && <span className="text-danger"> *</span>}
</label>
{children}
{error && <p className="mt-1 text-tiny text-danger">{error}</p>}
</div>
);
}
// ============ FormInput 受控输入 ============
export interface FormInputProps {
name: string;
type?: string;
placeholder?: string;
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
className?: string;
}
export function FormInput({
name,
type = "text",
placeholder,
value,
onChange,
disabled,
className,
}: FormInputProps): ReactNode {
return (
<input
id={name}
name={name}
type={type}
placeholder={placeholder}
value={value}
disabled={disabled}
onChange={
onChange
? (e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value)
: undefined
}
className={cn(
"w-full rounded-button border border-rule bg-paper px-3 py-2 text-sm text-ink focus:outline-none disabled:opacity-50",
className,
)}
/>
);
}
// ============ FormSelect 受控下拉 ============
export interface SelectOption {
value: string;
label: string;
}
export interface FormSelectProps {
name: string;
options: SelectOption[];
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
className?: string;
}
export function FormSelect({
name,
options,
value,
onChange,
disabled,
className,
}: FormSelectProps): ReactNode {
return (
<select
id={name}
name={name}
value={value}
disabled={disabled}
onChange={
onChange
? (e: ChangeEvent<HTMLSelectElement>) => onChange(e.target.value)
: undefined
}
className={cn(
"w-full rounded-button border border-rule bg-paper px-3 py-2 text-sm text-ink focus:outline-none disabled:opacity-50",
className,
)}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}
// ============ FormTextarea 受控文本域 ============
export interface FormTextareaProps {
name: string;
rows?: number;
placeholder?: string;
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
className?: string;
}
export function FormTextarea({
name,
rows = 3,
placeholder,
value,
onChange,
disabled,
className,
}: FormTextareaProps): ReactNode {
return (
<textarea
id={name}
name={name}
rows={rows}
placeholder={placeholder}
value={value}
disabled={disabled}
onChange={
onChange
? (e: ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)
: undefined
}
className={cn(
"w-full rounded-button border border-rule bg-paper px-3 py-2 text-sm text-ink focus:outline-none disabled:opacity-50",
className,
)}
/>
);
}
// ============ FormCheckbox 受控复选框 ============
export interface FormCheckboxProps {
name: string;
label: ReactNode;
checked?: boolean;
onChange?: (checked: boolean) => void;
disabled?: boolean;
className?: string;
}
export function FormCheckbox({
name,
label,
checked,
onChange,
disabled,
className,
}: FormCheckboxProps): ReactNode {
return (
<label
className={cn(
"flex items-center gap-2 text-sm text-ink",
disabled && "opacity-50",
className,
)}
>
<input
type="checkbox"
name={name}
checked={checked}
disabled={disabled}
onChange={
onChange
? (e: ChangeEvent<HTMLInputElement>) => onChange(e.target.checked)
: undefined
}
className="rounded border-rule"
/>
{label}
</label>
);
}
// ============ SubmitButton 提交按钮 ============
export interface SubmitButtonProps {
children: ReactNode;
loading?: boolean;
disabled?: boolean;
className?: string;
}
export function SubmitButton({
children,
loading,
disabled,
className,
}: SubmitButtonProps): ReactNode {
return (
<button
type="submit"
disabled={disabled || loading}
className={cn(
"rounded-button bg-accent px-4 py-2 text-sm text-ink-on-accent hover:bg-accent-hover disabled:opacity-50",
className,
)}
>
{loading ? "提交中..." : children}
</button>
);
}

View File

@@ -9,13 +9,17 @@
* - Loading骨架屏 / 加载占位
* - Empty空态展示
* - RequirePermissionL3 组件级视口控制
* - DataTable通用数据表格
* - FilterBar通用筛选栏
* - StatusBadge状态徽章
* - Modal模态对话框
* - Form轻量级表单Form/FormField/FormInput/FormSelect/FormTextarea/FormCheckbox/SubmitButton
* - cn类名合并工具project_rules §3.9
*
* 后续扩展P2+
* - AppShellMF Shell 布局)
* - shadcn/ui 基础组件Button/Input/Select/Modal/Toast/DataTable
* - shadcn/ui 基础组件Button/Input/Select/Toast
* - A11y 工具集useA11yId/mergeA11yProps/describeInput/focus-trap
* - Formreact-hook-form + zodResolver
* - RichTextEditorTiptap 封装)
* - Chartrecharts 封装)
*/
@@ -35,4 +39,36 @@ export type { EmptyProps } from "./empty.js";
export { RequirePermission } from "./require-permission.js";
export type { RequirePermissionProps } from "./require-permission.js";
export { DataTable } from "./data-table.js";
export type { Column, ColumnAlign, DataTableProps } from "./data-table.js";
export { FilterBar } from "./filter-bar.js";
export type { FilterBarProps } from "./filter-bar.js";
export { StatusBadge } from "./status-badge.js";
export type { StatusBadgeProps, StatusVariant } from "./status-badge.js";
export { Modal } from "./modal.js";
export type { ModalProps, ModalSize } from "./modal.js";
export {
Form,
FormField,
FormInput,
FormSelect,
FormTextarea,
FormCheckbox,
SubmitButton,
} from "./form.js";
export type {
FormProps,
FormFieldProps,
FormInputProps,
FormSelectProps,
FormTextareaProps,
FormCheckboxProps,
SubmitButtonProps,
SelectOption,
} from "./form.js";
export { cn } from "./utils/cn.js";

View File

@@ -0,0 +1,100 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { cn } from "./utils/cn.js";
/**
* Modal - 模态对话框
*
* 用途:覆盖在页面之上的居中弹窗,用于编辑表单、确认操作等。
* 支持 ESC 关闭、点击遮罩关闭。
*
* @example
* <Modal open={isOpen} onClose={() => setOpen(false)} title="编辑" size="lg"
* footer={<><button>取消</button><button>保存</button></>}
* >
* <p>内容</p>
* </Modal>
*/
export type ModalSize = "sm" | "md" | "lg" | "xl";
export interface ModalProps {
/** 是否打开 */
open: boolean;
/** 关闭回调ESC / 点击遮罩时触发) */
onClose: () => void;
/** 标题 */
title?: ReactNode;
/** 内容区 */
children: ReactNode;
/** 底部按钮区 */
footer?: ReactNode;
/** 尺寸 */
size?: ModalSize;
/** 自定义类名 */
className?: string;
}
const SIZE_CLASS: Record<ModalSize, string> = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-2xl",
};
export function Modal({
open,
onClose,
title,
children,
footer,
size = "md",
className,
}: ModalProps): ReactNode {
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [open, onClose]);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/50 backdrop-blur-sm"
>
<div
onClick={(e) => e.stopPropagation()}
className={cn(
"max-h-[90vh] w-full overflow-y-auto rounded-card border border-rule bg-paper p-6 shadow-xl",
SIZE_CLASS[size],
className,
)}
>
{title && (
<>
<h2 className="text-lg font-serif text-ink">{title}</h2>
<div className="rule-thin mb-4 mt-2" />
</>
)}
<div className="text-ink">{children}</div>
{footer && (
<div className="mt-6 flex items-center justify-end gap-3">
{footer}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import type { ReactNode } from "react";
import { cn } from "./utils/cn.js";
/**
* StatusBadge - 状态徽章
*
* 用途:用简短的彩色标签展示状态(如"已发布"/"待审核"/"已拒绝")。
* 未指定 variant 时根据 status 字符串自动推断语义色。
*
* @example
* <StatusBadge status="published" />
* <StatusBadge status="custom" variant="info">自定义文案</StatusBadge>
*/
export type StatusVariant =
"success" | "warning" | "danger" | "info" | "neutral";
export interface StatusBadgeProps {
/** 状态字符串(用于推断 variant 及默认文案) */
status: string;
/** 显式指定变体;未提供时根据 status 自动推断 */
variant?: StatusVariant;
/** 自定义文案;未提供时显示 status */
children?: ReactNode;
/** 自定义类名 */
className?: string;
}
/** status → variant 自动推断映射 */
const STATUS_VARIANT_MAP: Record<string, StatusVariant> = {
// success
approved: "success",
active: "success",
published: "success",
completed: "success",
success: "success",
done: "success",
passed: "success",
// warning
pending: "warning",
draft: "warning",
review: "warning",
waiting: "warning",
processing: "warning",
// danger
rejected: "danger",
cancelled: "danger",
failed: "danger",
error: "danger",
inactive: "danger",
deleted: "danger",
// info
info: "info",
new: "info",
syncing: "info",
loading: "info",
};
/** variant → Tailwind 类名映射 */
const VARIANT_CLASS: Record<StatusVariant, string> = {
success: "border-success text-success bg-success/10",
warning: "border-warning text-warning bg-warning/10",
danger: "border-danger text-danger bg-danger/10",
info: "border-info text-info bg-info/10",
neutral: "border-rule text-ink-muted bg-subtle",
};
function inferVariant(status: string): StatusVariant {
const normalized = status.toLowerCase();
return STATUS_VARIANT_MAP[normalized] ?? "neutral";
}
export function StatusBadge({
status,
variant,
children,
className,
}: StatusBadgeProps): ReactNode {
const resolvedVariant = variant ?? inferVariant(status);
return (
<span
className={cn(
"inline-flex items-center rounded-button border px-2 py-0.5 text-tiny font-medium",
VARIANT_CLASS[resolvedVariant],
className,
)}
>
{children ?? status}
</span>
);
}