Files
lingxi/apps/web/src/pages/system/settings/config-utils.ts
T
Stev_Wang 3b0a99d91f feat(web): A4 数据字典与系统配置页
背景:用户选定方案 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 无溢出。
2026-11-04 11:49:32 +08:00

241 lines
8.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.
/**
* 系统配置页纯函数:分组、草稿校验、命名模板预览与展示格式化。
*
* 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 : '命名预览失败',
};
}
}