feat(portal-shell): 管理域 §9.4 B5 全量迁移(24 + 21 补充批次共 44 页 + 42 features)

按 ARCHITECTURE.md §9.4 规划口径 + admin-NeedTodo.md §四补充批次完成管理域全量页面迁移:

【§9.4 规划 24 页(B5)】
- users(2) + roles(1) + permissions(1) + audit-logs(4) + invitation-codes(1)
- school(6: redirect/schools/classes/departments/academic-year/grades)
- announcements(1) + files(1) + ai-settings(1) + system(1) + viewports(1)
- students(1) + teachers(1) + organization(1) + plugins(1, config-service)
- 仪表盘已存在(/shell/admin/page.tsx)

【§四补充批次 21 页】
- course-plans(4) + elective(4) + questions(1) + lesson-plans(2) + error-book(1)
- scheduling(3: auto/changes/rules) + attendance(1) + curriculum-map(1)
- announcements 详情/编辑(2) + roles/[id] 详情(1) + users/import(1)

【实现要点】
- 全部使用 ListPageShell + loading/error/empty 三态规范(§11.3 DoD)
- 走 lib/api hooks;未就绪契约走 MSW + @contract-pending 注释(§11.4)
- 文案走 useTranslations(zh-CN + en 两份同步更新)
- 42 个 features/<domain>/transformations.ts 纯函数 + 配套 vitest 单测
- catch 块统一 notify.error;无空 catch;lint:tokens 通过
- 路由全部登记到 route-permissions.ts(39 EXACT + 8 PREFIX)

【验收】
- tsc --noEmit: 0 errors
- ESLint src: 0 errors (4 generated-files warnings, pre-existing)
- lint:tokens: 0 errors
- vitest: 1639/1639 passed (含 23 admin 测试文件 671 用例)
- check:routes: PASS (143 routes, 4 ghost entries pre-existing)
- check:pages: PASS (146 pages)
- check:codegen: PASS
- arch:scan: 24 modules, 8262 symbols

关联:ARCHITECTURE.md §9.4 / §10 P5 / §11.3 DoD / §11.6
This commit is contained in:
SpecialX
2026-07-24 23:07:20 +08:00
parent 5a9f652943
commit 062d9e9582
394 changed files with 60468 additions and 118 deletions

View File

@@ -0,0 +1,235 @@
"use client";
/**
* 自动排课页 - 客户端组件ARCHITECTURE.md §7.3 列表页 / §9.4 / §10 P5 / admin-NeedTodo §四)
*
* 数据契约:
* - adminClasses():❌ schema 无 → MSW 兜底(@contract-pending
* - autoSchedule(classId) mutation❌ schema 无 → MSW 兜底
*
* URL 状态:无(操作型页面,状态本地维护)
*
* 三态规范§11.3 DoDloading骨架/ error局部降级/ emptyEmptyState + 行动按钮)
*
* 关联ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4
*/
import { CalendarClock } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useAdminClasses, useAutoSchedule } from "@/lib/api";
import { notify } from "@/shared/lib/notify";
import { Button } from "@/shared/components/ui/button";
import { EmptyState } from "@/shared/components/ui/empty-state";
import {
ListPageShell,
ListPageSkeleton,
} from "@/shared/components/page-templates";
import {
formatGeneratedEntries,
hasConflicts,
scheduleStatusToBadgeClass,
scheduleStatusToKey,
type ScheduleStatus,
} from "@/features/admin/scheduling/transformations";
/** 班级排课结果本地状态 */
interface ClassScheduleResult {
status: ScheduleStatus;
generatedEntries?: number;
conflicts?: string[];
}
/**
* 自动排课客户端主体。需由 server page 包裹在 <Suspense> 中。
*/
export function AutoScheduleClient(): React.ReactElement {
const t = useTranslations("admin.scheduling.auto");
const tCommon = useTranslations("common");
// @contract-pendingMSW 兜底
const { data, loading, error } = useAdminClasses();
const { run: autoSchedule, loading: running } = useAutoSchedule();
const [results, setResults] = useState<Record<string, ClassScheduleResult>>(
{},
);
const classes = data ?? [];
const handleStart = async (classId: string): Promise<void> => {
if (!window.confirm(t("startConfirm"))) return;
// 标记为 pending覆盖之前结果
setResults((prev) => ({
...prev,
[classId]: { status: "pending" },
}));
try {
const result = await autoSchedule(classId);
const nextStatus: ScheduleStatus = hasConflicts(result.conflicts)
? "failed"
: "scheduled";
setResults((prev) => ({
...prev,
[classId]: {
status: nextStatus,
generatedEntries: result.generatedEntries,
conflicts: result.conflicts,
},
}));
if (nextStatus === "scheduled") {
notify.success(t("startSuccess", { count: result.generatedEntries }));
} else {
notify.error(t("startError"));
}
} catch (err) {
setResults((prev) => ({
...prev,
[classId]: { status: "failed", conflicts: [String(err)] },
}));
notify.error(`${t("startError")}: ${String(err)}`);
}
};
const errorNode = error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-destructive">
{tCommon("error.loadFailed", { message: String(error) })}
</p>
<p className="mt-2 text-xs text-muted-foreground">{t("mswNotice")}</p>
</div>
) : undefined;
const emptyNode = (
<EmptyState
icon={CalendarClock}
title={t("emptyTitle")}
description={t("emptyDescription")}
action={{
label: t("emptyAction"),
href: "/shell/admin/school/classes",
}}
/>
);
return (
<ListPageShell
title={t("title")}
description={t("description")}
icon={<CalendarClock className="size-6" />}
actions={
<Button asChild variant="outline">
<Link href="/shell/admin/scheduling/rules">
{t("configureRulesButton")}
</Link>
</Button>
}
loading={loading}
loadingNode={<ListPageSkeleton rows={5} />}
empty={classes.length === 0 && !loading}
emptyNode={emptyNode}
errorNode={errorNode}
>
<AutoScheduleTable
classes={classes}
results={results}
running={running}
onStart={handleStart}
/>
<p className="text-xs text-muted-foreground">{t("mswNotice")}</p>
</ListPageShell>
);
}
/**
* 班级自动排课表格。
*/
function AutoScheduleTable({
classes,
results,
running,
onStart,
}: {
classes: NonNullable<ReturnType<typeof useAdminClasses>["data"]>;
results: Record<string, ClassScheduleResult>;
running: boolean;
onStart: (classId: string) => Promise<void>;
}): React.ReactElement {
const t = useTranslations("admin.scheduling.auto");
return (
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr>
<th className="p-3 text-left font-medium">{t("colClassName")}</th>
<th className="p-3 text-left font-medium">{t("colGrade")}</th>
<th className="p-3 text-left font-medium">{t("colTeacher")}</th>
<th className="p-3 text-left font-medium">
{t("colSubjectCount")}
</th>
<th className="p-3 text-left font-medium">{t("colStatus")}</th>
<th className="p-3 text-right font-medium">{t("startButton")}</th>
</tr>
</thead>
<tbody className="divide-y">
{classes.map((cls) => {
const result = results[cls.id];
const statusKey = scheduleStatusToKey(result?.status ?? "pending");
const statusLabel = t(
`status${statusKey.charAt(0).toUpperCase()}${statusKey.slice(1)}` as
"statusPending" | "statusScheduled" | "statusFailed",
);
return (
<tr key={cls.id} className="hover:bg-muted/30">
<td className="p-3 font-medium">{cls.name}</td>
<td className="p-3 text-muted-foreground">{cls.gradeName}</td>
<td className="p-3 text-muted-foreground">
{cls.headTeacherName || "--"}
</td>
<td className="p-3 font-mono text-xs">{cls.subjectCount}</td>
<td className="p-3">
<StatusBadge status={statusKey} label={statusLabel} />
{result?.generatedEntries != null ? (
<span className="ml-2 text-xs text-muted-foreground">
({formatGeneratedEntries(result.generatedEntries)})
</span>
) : null}
</td>
<td className="p-3 text-right">
<Button
size="sm"
disabled={running}
onClick={() => void onStart(cls.id)}
>
{t("startButton")}
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
/**
* 排课状态徽章。
*/
function StatusBadge({
status,
label,
}: {
status: ScheduleStatus;
label: string;
}): React.ReactElement {
const cls = scheduleStatusToBadgeClass(status);
return (
<span
className={`inline-flex h-6 items-center rounded-full px-2 text-xs font-medium ${cls}`}
>
{label}
</span>
);
}