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
This commit is contained in:
280
apps/portal-shell/src/features/admin/elective/transformations.ts
Normal file
280
apps/portal-shell/src/features/admin/elective/transformations.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 选修课管理数据变换工具(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;
|
||||
}
|
||||
Reference in New Issue
Block a user