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,280 @@
/**
* 选修课管理数据变换工具ARCHITECTURE.md §11.3 DoD - 纯函数单测)
*
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
* 关联ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
*/
/** 选修课状态中文标签映射(与 admin.elective.list i18n 对齐) */
export const ELECTIVE_STATUS_LABEL: Record<string, string> = {
DRAFT: "草稿",
OPEN: "报名中",
CLOSED: "已关闭",
FULL: "已满",
};
/** 已知选修课状态枚举 */
export type AdminElectiveStatus = "DRAFT" | "OPEN" | "CLOSED" | "FULL";
/**
* 防御性读取字段:兼容 schema 声明的字段名subjectName与 MSW mock 字段名subject
* 用于规避 @contract-pending 阶段的 mock/schema 形状不一致。
*/
export interface FlexibleElectiveItem {
id?: string;
name?: string;
description?: string;
subjectId?: string;
subjectName?: string;
subject?: unknown;
gradeId?: string;
gradeName?: string;
gradeLevel?: unknown;
teacherId?: string;
teacherName?: string;
capacity?: number;
selectedCount?: number;
enrolledCount?: unknown;
status?: string;
startDate?: string;
endDate?: string;
createdAt?: string;
updatedAt?: string;
}
/**
* 将选修课状态枚举值映射为中文标签。
* 未知状态回退为原始值。
*/
export function formatElectiveStatus(status: string): string {
return ELECTIVE_STATUS_LABEL[status] ?? status;
}
/**
* 根据选修课状态返回 Tailwind 徽章语义类名。
*/
export function electiveStatusToBadgeClass(status: string): string {
switch (status) {
case "DRAFT":
return "bg-muted text-muted-foreground";
case "OPEN":
return "bg-primary/10 text-primary";
case "CLOSED":
return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
case "FULL":
return "bg-destructive/10 text-destructive";
default:
return "bg-muted text-muted-foreground";
}
}
/**
* 安全读取科目名称(防御 MSW 与 schema 字段不一致)。
* 优先返回 subjectName缺失时回退到 subject最终回退占位符。
*/
export function getSubjectName(item: FlexibleElectiveItem): string {
if (typeof item.subjectName === "string" && item.subjectName) {
return item.subjectName;
}
if (typeof item.subject === "string" && item.subject) {
return item.subject;
}
return "--";
}
/**
* 安全读取年级名称(防御 MSW 与 schema 字段不一致)。
* 优先返回 gradeName缺失时回退到 gradeLevel最终回退占位符。
*/
export function getGradeName(item: FlexibleElectiveItem): string {
if (typeof item.gradeName === "string" && item.gradeName) {
return item.gradeName;
}
if (typeof item.gradeLevel === "string" && item.gradeLevel) {
return item.gradeLevel;
}
return "--";
}
/**
* 安全读取已选人数(防御 MSW 与 schema 字段不一致)。
* 优先返回 selectedCount缺失时回退到 enrolledCount最终回退 0。
*/
export function getEnrolledCount(item: FlexibleElectiveItem): number {
if (
typeof item.selectedCount === "number" &&
Number.isFinite(item.selectedCount)
) {
return item.selectedCount;
}
if (
typeof item.enrolledCount === "number" &&
Number.isFinite(item.enrolledCount)
) {
return item.enrolledCount;
}
return 0;
}
/**
* 安全读取容量(防御 schema 字段缺失)。
*/
export function getCapacity(item: FlexibleElectiveItem): number {
if (typeof item.capacity === "number" && Number.isFinite(item.capacity)) {
return item.capacity;
}
return 0;
}
/**
* 计算报名率enrolled / capacity * 100
* capacity 为 0 或输入无效时返回 0。
*/
export function calcEnrollmentRate(item: {
capacity: number;
enrolledCount: number;
}): number {
if (
!Number.isFinite(item.capacity) ||
!Number.isFinite(item.enrolledCount) ||
item.capacity <= 0
) {
return 0;
}
const ratio = item.enrolledCount / item.capacity;
if (ratio < 0) return 0;
if (ratio > 1) return 100;
return Math.round(ratio * 100);
}
/**
* 根据报名率0-100返回 Tailwind 文本语义类名。
* - 100已满 → destructive
* - >= 90 → destructive
* - >= 50 → amber
* - > 0 → primary
* - == 0 → muted
*/
export function enrollmentRateToColorClass(rate: number): string {
if (!Number.isFinite(rate) || rate < 0 || rate > 100) {
return "text-muted-foreground";
}
if (rate >= 90) return "text-destructive";
if (rate >= 50) return "text-amber-600 dark:text-amber-400";
if (rate > 0) return "text-primary";
return "text-muted-foreground";
}
/**
* 格式化报名进度展示(如 "20/30")。
* 输入无效返回 "--"。
*/
export function formatEnrollmentCount(item: {
capacity: number;
enrolledCount: number;
}): string {
if (!Number.isFinite(item.capacity) || !Number.isFinite(item.enrolledCount)) {
return "--";
}
return `${item.enrolledCount}/${item.capacity}`;
}
/**
* 格式化 ISO 日期字符串为本地化展示zh-CN含年月日时分
* 输入无效时返回占位符。
*/
export function formatElectiveDate(isoDate: string | null | undefined): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* 格式化 ISO 日期为仅日期YYYY-MM-DD
* 输入无效时返回占位符。
*/
export function formatElectiveDateOnly(
isoDate: string | null | undefined,
): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
/**
* 判断选修课是否可编辑DRAFT 状态)。
*/
export function isElectiveEditable(status: string): boolean {
return status === "DRAFT";
}
/**
* 判断选修课状态字符串是否合法。
*/
export function isValidElectiveStatus(
status: string,
): status is AdminElectiveStatus {
return (
status === "DRAFT" ||
status === "OPEN" ||
status === "CLOSED" ||
status === "FULL"
);
}
/**
* 判断选修课是否还有名额enrolled < capacity
*/
export function hasAvailableSpot(item: {
capacity: number;
enrolledCount: number;
}): boolean {
if (
!Number.isFinite(item.capacity) ||
!Number.isFinite(item.enrolledCount) ||
item.capacity <= 0
) {
return false;
}
return item.enrolledCount < item.capacity;
}
/**
* 客户端搜索匹配:在 name / subjectName / teacherName 字段中匹配关键词(大小写不敏感)。
*/
export function matchElectiveSearch(
item: FlexibleElectiveItem,
q: string,
): boolean {
if (!q) return true;
const lower = q.toLowerCase();
const name = (item.name ?? "").toLowerCase();
const subject = getSubjectName(item).toLowerCase();
const teacher = (item.teacherName ?? "").toLowerCase();
return (
name.includes(lower) || subject.includes(lower) || teacher.includes(lower)
);
}
/**
* 客户端按状态过滤。
* status 为空字符串或 null 时不过滤。
*/
export function matchElectiveStatus(
item: FlexibleElectiveItem,
status: string | null | undefined,
): boolean {
if (!status) return true;
return item.status === status;
}