背景:用户选定方案 A 主从分栏,A4 需要落地字典与配置页。\n改动:新增 /system/dicts、/system/dicts/:type、/system/settings;主从类型列表 + 三层合并 + 树/词表切换 + 条目/类型弹层;6 组配置 + 命名预览 + 阈值二次确认 + 变更记录;客户字段上限改读 effective config;路由/样式/OpenAPI 类型同步。\n影响面:管理中心新增两个菜单页;客户页上限显示改为配置读取(fallback 共享常量)。\n回滚:回滚本提交并恢复 AppLayout / routes / main 样式 import。\n验证:web typecheck/lint/build;单测 154;浏览器自检 1440/1024 无溢出。
241 lines
8.0 KiB
TypeScript
241 lines
8.0 KiB
TypeScript
/**
|
||
* 系统配置页纯函数:分组、草稿校验、命名模板预览与展示格式化。
|
||
*
|
||
* R-26:校验与保存共用 shared 的 `validateSystemConfigValue` / `validateNamingTemplate`,
|
||
* 页面不再维护第二套阈值 / 模板规则。
|
||
*/
|
||
import {
|
||
type NamingContext,
|
||
type NamingTemplateCheck,
|
||
resolveNamingTemplate,
|
||
SYSTEM_CONFIG_GROUPS,
|
||
type SystemConfig,
|
||
systemConfigDefinition,
|
||
type SystemConfigGroupKey,
|
||
type SystemConfigValue,
|
||
validateNamingTemplate,
|
||
validateSystemConfigValue,
|
||
} from '@lingxi/shared';
|
||
|
||
export interface ConfigGroupView {
|
||
key: SystemConfigGroupKey;
|
||
name: string;
|
||
description: string;
|
||
items: SystemConfig[];
|
||
}
|
||
|
||
function itemSortOrder(config: SystemConfig): number {
|
||
return systemConfigDefinition(config.key)?.sortOrder ?? 9_999;
|
||
}
|
||
|
||
/** 按 6 个固定分组渲染,组内按配置定义排序;空分组保留(页面展示空态) */
|
||
export function configGroupViews(configs: readonly SystemConfig[]): ConfigGroupView[] {
|
||
return [...SYSTEM_CONFIG_GROUPS]
|
||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||
.map((group) => ({
|
||
key: group.key,
|
||
name: group.name,
|
||
description: group.description,
|
||
items: configs
|
||
.filter((config) => config.group === group.key)
|
||
.sort((left, right) => itemSortOrder(left) - itemSortOrder(right)),
|
||
}));
|
||
}
|
||
|
||
export function configKeyLabel(key: string): string {
|
||
return systemConfigDefinition(key)?.label ?? key;
|
||
}
|
||
|
||
/** 原始值转输入框字符串(布尔用 'true' / 'false') */
|
||
export function rawValueOf(config: SystemConfig): string {
|
||
if (typeof config.value === 'boolean') return config.value ? 'true' : 'false';
|
||
return String(config.value);
|
||
}
|
||
|
||
/** 输入框字符串按类型解析;非法输入返回 null(不抛错,用于行内校验) */
|
||
export function parseConfigRaw(config: SystemConfig, raw: string): SystemConfigValue | null {
|
||
if (config.type === 'boolean') {
|
||
if (raw === 'true') return true;
|
||
if (raw === 'false') return false;
|
||
return null;
|
||
}
|
||
if (config.type === 'number') {
|
||
const trimmed = raw.trim();
|
||
if (trimmed === '') return null;
|
||
const value = Number(trimmed);
|
||
return Number.isFinite(value) ? value : null;
|
||
}
|
||
return raw;
|
||
}
|
||
|
||
export function isConfigDirty(config: SystemConfig, raw: string): boolean {
|
||
const parsed = parseConfigRaw(config, raw);
|
||
if (parsed === null) return true;
|
||
return parsed !== config.value;
|
||
}
|
||
|
||
export function dirtyConfigKeys(
|
||
configs: readonly SystemConfig[],
|
||
overrides: Readonly<Record<string, string>>,
|
||
): string[] {
|
||
return configs
|
||
.filter((config) => {
|
||
const raw = overrides[config.key];
|
||
return raw !== undefined && isConfigDirty(config, raw);
|
||
})
|
||
.map((config) => config.key);
|
||
}
|
||
|
||
/** 行内校验:优先用 shared 定义校验,未知 key 按响应类型兜底 */
|
||
export function validateConfigRaw(config: SystemConfig, raw: string): string | null {
|
||
const parsed = parseConfigRaw(config, raw);
|
||
if (parsed === null) {
|
||
return config.type === 'number' ? '请输入有效数字' : '请输入合法值';
|
||
}
|
||
const definition = systemConfigDefinition(config.key);
|
||
if (definition !== undefined) {
|
||
const result = validateSystemConfigValue(definition, parsed);
|
||
return result.ok ? null : result.message;
|
||
}
|
||
if (config.type === 'pattern') {
|
||
const check = validateNamingTemplate(String(parsed), { maxLength: config.maxLength ?? 100 });
|
||
return check.valid ? null : check.errors.join(';');
|
||
}
|
||
if (typeof parsed === 'string' && config.maxLength !== null && parsed.length > config.maxLength) {
|
||
return `配置值不能超过 ${config.maxLength} 个字符`;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export interface ConfigChangeDraft {
|
||
config: SystemConfig;
|
||
value: SystemConfigValue;
|
||
oldValue: SystemConfigValue;
|
||
}
|
||
|
||
export interface CollectConfigChangesResult {
|
||
changes: ConfigChangeDraft[];
|
||
errors: Record<string, string>;
|
||
}
|
||
|
||
/** 保存前收集脏项 + 校验;错误按 key 返回给行内提示,失败时保留草稿可重试 */
|
||
export function collectConfigChanges(
|
||
configs: readonly SystemConfig[],
|
||
overrides: Readonly<Record<string, string>>,
|
||
): CollectConfigChangesResult {
|
||
const changes: ConfigChangeDraft[] = [];
|
||
const errors: Record<string, string> = {};
|
||
for (const config of configs) {
|
||
const raw = overrides[config.key];
|
||
if (raw === undefined) continue;
|
||
if (!isConfigDirty(config, raw)) continue;
|
||
const error = validateConfigRaw(config, raw);
|
||
if (error !== null) {
|
||
errors[config.key] = error;
|
||
continue;
|
||
}
|
||
const parsed = parseConfigRaw(config, raw);
|
||
if (parsed === null) continue;
|
||
changes.push({ config, value: parsed, oldValue: config.value });
|
||
}
|
||
return { changes, errors };
|
||
}
|
||
|
||
/** 阈值 / 上限类配置修改必须先二次确认(R-26) */
|
||
export function isGuardedConfig(config: SystemConfig): boolean {
|
||
return config.group === 'limits' || config.group === 'threshold';
|
||
}
|
||
|
||
export function hasGuardedChange(changes: readonly ConfigChangeDraft[]): boolean {
|
||
return changes.some((change) => isGuardedConfig(change.config));
|
||
}
|
||
|
||
export function latestUpdatedAt(items: readonly { updatedAt: string }[]): string | null {
|
||
let latest: string | null = null;
|
||
for (const item of items) {
|
||
if (latest === null || item.updatedAt > latest) latest = item.updatedAt;
|
||
}
|
||
return latest;
|
||
}
|
||
|
||
export function formatConfigValue(
|
||
value: unknown,
|
||
options: { type?: SystemConfig['type']; unit?: string | null } = {},
|
||
): string {
|
||
if (value === null || value === undefined) return '未配置';
|
||
if (options.type === 'boolean') return value === true || value === 'true' ? '开启' : '关闭';
|
||
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
|
||
return '—';
|
||
}
|
||
if (options.unit !== null && options.unit !== undefined && options.unit !== '') {
|
||
return `${String(value)} ${options.unit}`;
|
||
}
|
||
return String(value);
|
||
}
|
||
|
||
export function configValueText(config: SystemConfig, value: unknown): string {
|
||
return formatConfigValue(value, { type: config.type, unit: config.unit });
|
||
}
|
||
|
||
/** MM-DD HH:mm(系统配置页统一时间展示) */
|
||
export function formatConfigTime(iso: string | null): string {
|
||
if (iso === null || iso === '') return '—';
|
||
const date = new Date(iso);
|
||
if (Number.isNaN(date.getTime())) return '—';
|
||
const pad = (value: number): string => String(value).padStart(2, '0');
|
||
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||
}
|
||
|
||
/** 数值范围 / 模板长度提示 */
|
||
export function rangeTextOf(config: SystemConfig): string {
|
||
if (config.type === 'number' && (config.min !== null || config.max !== null)) {
|
||
const min = config.min ?? '';
|
||
const max = config.max ?? '';
|
||
return `范围 ${String(min)}–${String(max)}${config.unit === null ? '' : ` ${config.unit}`}`;
|
||
}
|
||
if (config.type === 'pattern') {
|
||
return `≤ ${config.maxLength ?? 100} 字符`;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 命名规则预览(与保存前校验共用 shared 函数)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** 预览用示例 context:电商 / 花间集 / 口播 / 20261104 / 01 / 1080x1920 */
|
||
export const NAMING_SAMPLE_CONTEXT: NamingContext = {
|
||
industry: '电商',
|
||
customer: '花间集',
|
||
direction: '口播',
|
||
date: '20261104',
|
||
sequence: '01',
|
||
spec: '1080x1920',
|
||
};
|
||
|
||
export interface NamingPreviewResult {
|
||
check: NamingTemplateCheck;
|
||
resolved: string | null;
|
||
error: string | null;
|
||
}
|
||
|
||
export function namingPreview(pattern: string, maxLength = 100): NamingPreviewResult {
|
||
const check = validateNamingTemplate(pattern, { maxLength });
|
||
if (!check.valid) {
|
||
return { check, resolved: null, error: check.errors.join(';') };
|
||
}
|
||
try {
|
||
return {
|
||
check,
|
||
resolved: resolveNamingTemplate(pattern, NAMING_SAMPLE_CONTEXT, { maxLength }),
|
||
error: null,
|
||
};
|
||
} catch (cause) {
|
||
return {
|
||
check,
|
||
resolved: null,
|
||
error: cause instanceof Error ? cause.message : '命名预览失败',
|
||
};
|
||
}
|
||
}
|