按 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
281 lines
7.4 KiB
TypeScript
281 lines
7.4 KiB
TypeScript
/**
|
||
* 选修课管理数据变换工具(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;
|
||
}
|