背景: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 已应用。
301 lines
9.1 KiB
TypeScript
301 lines
9.1 KiB
TypeScript
/**
|
||
* 命名工具(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');
|
||
}
|