Files
Edu/apps/portal-shell/src/features/admin/elective/transformations.ts
SpecialX 062d9e9582 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
2026-07-24 23:07:20 +08:00

281 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 选修课管理数据变换工具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;
}