/** * 命名工具(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, 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; 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 = 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'); }