Files
lingxi/packages/shared/src/naming.ts
T
Stev_Wang cc6e84cdbc feat(shared): A4 数据字典与系统配置契约
背景:A4 需要字典三层合并、配置版本与命名规则的共享契约。\n改动:新增 dict/config 契约、system-configs 定义与校验、命名模板解析;新增 dict_types/dict_items/system_config_versions 迁移与 13 类/96 条字典、26 项配置种子。\n影响面:shared 新增导出;prisma 新增表与迁移;客户基础字段 DTO 硬上界调整为 50(实际生效上限仍由配置动态校验)。\n回滚:回滚本提交并保留新增表(不删数据)。\n验证:shared typecheck/test 147;seed 幂等;prisma migrate dev 已应用。
2026-11-04 11:48:38 +08:00

301 lines
9.1 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.
/**
* 命名工具(A0-SH-01):文件名清洗 + 同名自动加序号(PRD R-01)
* 浏览器 / Node 通用,不依赖 node:path。
*/
const ILLEGAL_FILENAME_CHARS = /[\\/:*?"<>|]/g;
const RESERVED_FILENAMES = new Set([
'CON',
'PRN',
'AUX',
'NUL',
'COM1',
'COM2',
'COM3',
'COM4',
'COM5',
'COM6',
'COM7',
'COM8',
'COM9',
'LPT1',
'LPT2',
'LPT3',
'LPT4',
'LPT5',
'LPT6',
'LPT7',
'LPT8',
'LPT9',
]);
const MAX_FILENAME_LENGTH = 120;
export interface SanitizeFilenameOptions {
maxLength?: number;
replacement?: string;
fallback?: string;
}
/** 去掉控制字符(\u0000-\u001f、\u007f),保留常规空白 */
function stripControlChars(input: string): string {
let output = '';
for (const char of input) {
const code = char.codePointAt(0) ?? 0;
if (code >= 0x20 && code !== 0x7f) {
output += char;
}
}
return output;
}
/** 清洗文件名:去除非法字符 / 控制字符 / 首尾空格与点,中文原样保留 */
export function sanitizeFilename(input: string, options: SanitizeFilenameOptions = {}): string {
const maxLength = options.maxLength ?? MAX_FILENAME_LENGTH;
const replacement = options.replacement ?? '_';
const fallback = options.fallback ?? '未命名';
let name = stripControlChars(input.normalize('NFC'))
.replace(ILLEGAL_FILENAME_CHARS, replacement)
.trim();
name = name.replace(/^[.\s]+|[.\s]+$/g, '');
if (name.length === 0 || name.replaceAll(replacement, '').trim().length === 0) {
return fallback;
}
if (RESERVED_FILENAMES.has(name.toUpperCase())) {
name = `${name}_`;
}
if (name.length > maxLength) {
const dotIndex = name.lastIndexOf('.');
const extension = dotIndex > 0 ? name.slice(dotIndex) : '';
const base = dotIndex > 0 ? name.slice(0, dotIndex) : name;
const keep = Math.max(1, maxLength - extension.length);
name = `${base.slice(0, keep)}${extension}`;
}
return name;
}
const SEQUENCE_PATTERN = /^(.*)-(\d{2,})$/;
function splitExtension(name: string): { stem: string; extension: string } {
const dotIndex = name.lastIndexOf('.');
if (dotIndex <= 0 || dotIndex === name.length - 1) {
return { stem: name, extension: '' };
}
return { stem: name.slice(0, dotIndex), extension: name.slice(dotIndex) };
}
/**
* 同名自动加序号(R-01):从 base-02 开始尝试到 maxAttempts,全部占用则抛错。
* 已带序号的名字会基于其原始基名继续递增;扩展名保持不变。
*/
export function dedupeName(name: string, existing: ReadonlySet<string>, maxAttempts = 999): string {
if (!existing.has(name)) {
return name;
}
const { stem, extension } = splitExtension(name);
const matched = SEQUENCE_PATTERN.exec(stem);
const root = matched?.[1] ?? stem;
for (let sequence = 2; sequence <= maxAttempts; sequence += 1) {
const candidate = `${root}-${String(sequence).padStart(2, '0')}${extension}`;
if (!existing.has(candidate)) {
return candidate;
}
}
throw new RangeError(`同名文件过多,无法为「${name}」生成序号`);
}
// ---------------------------------------------------------------------------
// 命名模板(A4-BE-03 / PRD R-01)
// 占位符:{行业} {客户} {脚本方向} {日期} {序号} {规格},同时兼容英文 key。
// ---------------------------------------------------------------------------
export const NAMING_MAX_LENGTH = 100;
export const NAMING_TOKEN_DEFINITIONS = [
{
key: 'industry',
token: '{行业}',
aliases: ['{industry}'],
label: '行业',
example: '电商',
description: '客户所属末级行业名称',
},
{
key: 'customer',
token: '{客户}',
aliases: ['{customer}'],
label: '客户',
example: '花间集',
description: '客户名称(优先使用简称)',
},
{
key: 'direction',
token: '{脚本方向}',
aliases: ['{direction}'],
label: '脚本方向',
example: '口播',
description: '来自字典「脚本方向」',
},
{
key: 'date',
token: '{日期}',
aliases: ['{date}'],
label: '日期',
example: '20261104',
description: 'YYYYMMDD,取生成当天',
},
{
key: 'sequence',
token: '{序号}',
aliases: ['{seq}', '{sequence}'],
label: '序号',
example: '01',
description: '同名自动递增,2 位起',
},
{
key: 'spec',
token: '{规格}',
aliases: ['{spec}'],
label: '规格',
example: '1080x1920',
description: '图片规格后缀,仅图片线可用',
},
] as const;
export type NamingTokenKey = (typeof NAMING_TOKEN_DEFINITIONS)[number]['key'];
export type NamingContext = Record<NamingTokenKey, string | number>;
export interface NamingTemplateCheck {
valid: boolean;
errors: string[];
/** 模板中出现的规范 token key(去重、按出现顺序) */
tokens: NamingTokenKey[];
unknownTokens: string[];
/** 模板字符长度(未解析,用于设置页展示) */
length: number;
}
const NAMING_TOKEN_PATTERN = /\{([^{}]+)\}/g;
const NAMING_ALLOWED_CHARS = /^[\u4e00-\u9fa5A-Za-z0-9_-]+$/;
const NAMING_TOKEN_MAP: ReadonlyMap<string, NamingTokenKey> = new Map(
NAMING_TOKEN_DEFINITIONS.flatMap((definition) =>
[definition.token, ...definition.aliases].map((token) => {
const key = token.slice(1, -1);
return [key, definition.key] as const;
}),
),
);
function tokensOf(template: string): { keys: NamingTokenKey[]; unknown: string[] } {
const keys: NamingTokenKey[] = [];
const unknown: string[] = [];
for (const match of template.matchAll(NAMING_TOKEN_PATTERN)) {
const raw = match[1];
if (raw === undefined) continue;
const key = NAMING_TOKEN_MAP.get(raw);
if (key === undefined) {
if (!unknown.includes(raw)) unknown.push(raw);
} else if (!keys.includes(key)) {
keys.push(key);
}
}
return { keys, unknown };
}
/** 校验模板:占位符是否可识别、模板字符是否在白名单内、长度是否超限 */
export function validateNamingTemplate(
template: string,
options: { maxLength?: number } = {},
): NamingTemplateCheck {
const maxLength = options.maxLength ?? NAMING_MAX_LENGTH;
const value = template.trim();
const errors: string[] = [];
if (value.length === 0) {
errors.push('命名模板不能为空');
}
if (value.length > maxLength) {
errors.push(`命名模板不能超过 ${String(maxLength)} 个字符`);
}
const { keys, unknown } = tokensOf(value);
if (unknown.length > 0) {
errors.push(`存在未识别的占位符:${unknown.map((item) => `{${item}}`).join('、')}`);
}
const remaining = value.replace(NAMING_TOKEN_PATTERN, '');
if (remaining.length > 0 && !NAMING_ALLOWED_CHARS.test(remaining)) {
errors.push('命名模板只能包含中文、字母、数字、下划线、短横线与占位符');
}
return {
valid: errors.length === 0,
errors,
tokens: keys,
unknownTokens: unknown,
length: value.length,
};
}
function normalizeNamingValue(value: string | number): string {
return String(value).trim().replace(/\s+/g, '-').replace(/-+/g, '-');
}
/**
* 解析命名模板。缺失占位符 / 非法字符 / 超长时抛错,调用方映射 42201。
* 同日同名序号由 dedupeName 处理,不在本函数内。
*/
export function resolveNamingTemplate(
template: string,
context: NamingContext,
options: { maxLength?: number } = {},
): string {
const maxLength = options.maxLength ?? NAMING_MAX_LENGTH;
const check = validateNamingTemplate(template, { maxLength });
if (!check.valid) {
throw new Error(check.errors.join(';'));
}
const missing: string[] = [];
const resolved = template.trim().replace(NAMING_TOKEN_PATTERN, (_match, raw: string) => {
const key = NAMING_TOKEN_MAP.get(raw);
if (key === undefined) return '';
const value = context[key];
if (String(value).trim() === '') {
const definition = NAMING_TOKEN_DEFINITIONS.find((item) => item.key === key);
missing.push(definition?.token ?? `{${raw}}`);
return '';
}
return normalizeNamingValue(value);
});
if (missing.length > 0) {
throw new Error(`命名占位符缺少值:${missing.join('、')}`);
}
if (!NAMING_ALLOWED_CHARS.test(resolved)) {
throw new Error('解析后的名称只能包含中文、字母、数字、下划线或短横线');
}
if (resolved.length > maxLength) {
throw new Error(`解析后的名称不能超过 ${String(maxLength)} 个字符`);
}
return resolved;
}
/** YYYYMMDD(按本地时区取日期,避免 UTC 跨日) */
export function formatNamingDate(value: Date | string): string {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
throw new Error('无效的日期');
}
const year = String(date.getFullYear());
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}${month}${day}`;
}
/** 序号补零;默认 2 位,超出宽度不截断 */
export function formatNamingSequence(sequence: number, width = 2): string {
if (!Number.isInteger(sequence) || sequence < 0) {
throw new Error('序号必须是非负整数');
}
return String(sequence).padStart(width, '0');
}