按 ARCHITECTURE.md 与 admin-NeedTodo.md 要求补齐所有管理页面缺失功能: - users/roles/permissions:权限矩阵搜索/折叠、zod 校验、value 字段 - audit-logs:行内详情对话框、分页页码、ChartCardShell - school:CRUD 对话框、GradeOverviewCards、academic-year 侧栏 - announcements/invitation-codes/ai-settings:发布按钮、分页、zod 校验 - course-plans/elective:Select 导入、undefined 处理 - error-book/scheduling/questions/lesson-plans/attendance:统计卡片 验证:typecheck 0 错误、arch:scan 已更新
309 lines
8.5 KiB
TypeScript
309 lines
8.5 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;
|
||
/** 教室(@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<string, string> = {
|
||
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;
|
||
}
|