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 无溢出。
This commit is contained in:
Stev_Wang committed 2026-11-04 11:49:32 +08:00
1 parent e4330a43e4
commit 3b0a99d91f
36 files changed
+7691 -15

No files matched your search

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