Files
Edu/apps/portal-shell/src/features/admin/ai-settings/transformations.ts
SpecialX 062d9e9582 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
2026-07-24 23:07:20 +08:00

112 lines
3.0 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.
/**
* AI Settings 数据变换工具ARCHITECTURE.md §11.3 DoD - 纯函数单测)
*
* 所有格式化/映射函数均为纯函数,便于 vitest 单测。
* 关联ARCHITECTURE.md §11.3 DoD "数据变换/权限判断等纯函数有 vitest 单测"
*/
/** AI Provider 类型标签映射 */
export const PROVIDER_TYPE_LABEL: Record<string, string> = {
openai: "OpenAI",
anthropic: "Anthropic",
azure: "Azure OpenAI",
local: "本地模型",
};
/** AI Provider 类型支持的取值列表 */
export const PROVIDER_TYPES: readonly string[] = [
"openai",
"anthropic",
"azure",
"local",
] as const;
/**
* 将 Provider 类型代码映射为展示标签。未知值回退为原始值。
*/
export function formatProviderType(type: string): string {
return PROVIDER_TYPE_LABEL[type] ?? type;
}
/**
* 判断字符串是否为受支持的 Provider 类型。
*/
export function isValidProviderType(type: string): boolean {
return PROVIDER_TYPES.includes(type);
}
/**
* 根据 isActive 返回 Tailwind 徽章语义类名。
*/
export function activeToBadgeClass(isActive: boolean): string {
return isActive
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-muted text-muted-foreground";
}
/**
* 根据 isActive 返回展示标签。
*/
export function formatActiveLabel(isActive: boolean): string {
return isActive ? "active" : "inactive";
}
/**
* 格式化 AI 用量费用(分)为元展示字符串。
* 输入无效返回 "¥0.00"。
*/
export function formatCostCents(costCents: number): string {
if (!Number.isFinite(costCents) || costCents < 0) return "¥0.00";
const yuan = costCents / 100;
return `¥${yuan.toFixed(2)}`;
}
/**
* 格式化数字为千分位展示字符串。
* 输入无效返回 "0"。
*/
export function formatNumber(value: number): string {
if (!Number.isFinite(value) || value < 0) return "0";
return value.toLocaleString("zh-CN");
}
/**
* 截断 API Key 用于列表展示(仅保留前 4 + 后 4 字符,中间以 ... 占位)。
* 输入为空返回占位符 "--"。
*/
export function maskApiKey(apiKey: string | null | undefined): string {
if (!apiKey || apiKey.trim().length === 0) return "--";
const text = apiKey.trim();
if (text.length <= 8) return text;
return `${text.slice(0, 4)}...${text.slice(-4)}`;
}
/**
* 截断 BaseUrl 用于列表展示(超过 maxLen 字符时截断并加省略号)。
* maxLen 默认 40。
*/
export function truncateBaseUrl(
url: string | null | undefined,
maxLen = 40,
): string {
if (!url) return "--";
const text = url.trim();
if (text.length <= maxLen) return text;
return `${text.slice(0, maxLen)}...`;
}
/**
* 格式化 ISO 日期字符串为本地化展示zh-CN仅年月日
* 输入无效时返回占位符。
*/
export function formatAiDate(isoDate: string | null | undefined): string {
if (!isoDate) return "--";
const d = new Date(isoDate);
if (Number.isNaN(d.getTime())) return "--";
return d.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}