Files
Edu/apps/portal-shell/src/features/admin/elective/transformations.ts
SpecialX f991bf0446 feat(portal-shell): 管理域全模块功能补齐与差异修复
按 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 已更新
2026-07-30 17:49:49 +08:00

309 lines
8.5 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;
/** 教室(@contract-pending对齐 CICD 列表"教室"列) */
classroom?: string;
/** 上课时间(@contract-pending对齐 CICD 列表"时间"列) */
schedule?: string;
/** 学分(@contract-pending对齐 CICD 列表"学分"列) */
credit?: number;
/** 选课模式(@contract-pendingFIRST_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;
}