/** * 选修课管理数据变换工具(ARCHITECTURE.md §11.3 DoD - 纯函数单测) * * 所有格式化/映射函数均为纯函数,便于 vitest 单测。 * 关联:ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测" */ /** 选修课状态中文标签映射(与 admin.elective.list i18n 对齐) */ export const ELECTIVE_STATUS_LABEL: Record = { 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; /** 教室(@contract-pending,对齐 CICD 列表"教室"列) */ classroom?: string; /** 上课时间(@contract-pending,对齐 CICD 列表"时间"列) */ schedule?: string; /** 学分(@contract-pending,对齐 CICD 列表"学分"列) */ credit?: number; /** 选课模式(@contract-pending:FIRST_COME | LOTTERY) */ selectionMode?: string; /** 选课开始时间 ISO(@contract-pending) */ selectionStartAt?: string; /** 选课结束时间 ISO(@contract-pending) */ selectionEndAt?: string; /** 退课截止时间 ISO(@contract-pending) */ dropDeadline?: string; } /** * 将选修课状态枚举值映射为中文标签。 * 未知状态回退为原始值。 */ export function formatElectiveStatus(status: string): string { return ELECTIVE_STATUS_LABEL[status] ?? status; } /** 选课模式枚举(@contract-pending) */ export type ElectiveSelectionMode = "FIRST_COME" | "LOTTERY"; /** 选课模式中文标签映射(与 admin.elective.list i18n 对齐) */ export const ELECTIVE_SELECTION_MODE_LABEL: Record = { FIRST_COME: "先到先得", LOTTERY: "抽签", }; /** * 将选课模式枚举值映射为中文标签。 * 未知模式回退为原始值。 */ export function formatElectiveSelectionMode(mode: string): string { return ELECTIVE_SELECTION_MODE_LABEL[mode] ?? mode; } /** * 根据选修课状态返回 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; }