#!/usr/bin/env node import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import path from "node:path"; import process from "node:process"; const rootDir = process.cwd(); const strict = process.argv.includes("--strict"); const jsonOutput = process.argv.includes("--json"); const scanRoots = [ "client/src/components", "client/src/features/navigation", "client/src/layouts", "client/src/pages", ]; const sourceExtensions = new Set([".js", ".jsx", ".ts", ".tsx"]); const forbiddenCopy = ["先处理", "清爽"]; const userFacingBusinessTermFiles = new Set([ "client/src/pages/MobileCapturePage.tsx", "client/src/pages/MobileNotificationsPage.tsx", "client/src/pages/MobileProfilePage.tsx", "client/src/pages/MobileReimbursementDetailPage.tsx", "client/src/pages/ReimbursementDetailPage.tsx", "client/src/components/travel/TravelPaymentTrackCard.tsx", ]); const forbiddenUserFacingBusinessTerms = ["绑定整单", "不绑定明细", "发票池", "OCR"]; const findings = []; function toPosix(value) { return value.split(path.sep).join("/"); } function collectFiles(dir) { if (!existsSync(dir)) { return []; } const entries = readdirSync(dir); const files = []; for (const entry of entries) { const fullPath = path.join(dir, entry); const stat = statSync(fullPath); if (stat.isDirectory()) { files.push(...collectFiles(fullPath)); continue; } if (sourceExtensions.has(path.extname(fullPath))) { files.push(fullPath); } } return files; } function lineNumber(source, index) { return source.slice(0, index).split("\n").length; } function addFinding({ rule, severity = "warning", file, line = 1, message, evidence }) { findings.push({ rule, severity, file: toPosix(path.relative(rootDir, file)), line, message, evidence, }); } function scanForbiddenCopy(file, source) { for (const phrase of forbiddenCopy) { let start = source.indexOf(phrase); while (start !== -1) { addFinding({ rule: "copy.no_conversational_labels", severity: "error", file, line: lineNumber(source, start), message: `避免口语化或内部评价文案:${phrase}`, evidence: phrase, }); start = source.indexOf(phrase, start + phrase.length); } } } function scanMobileDarkControls(file, source) { const normalized = toPosix(file); const mobileScope = /\/Mobile|\/mobile\//.test(normalized) || /mobile/i.test(path.basename(file)); if (!mobileScope || normalized.endsWith("client/src/components/mobile/BarcodeScannerPanel.tsx")) { return; } const darkClassPattern = /\bbg-(?:black|zinc-900|slate-900|neutral-900|stone-900|gray-900)\b/g; let match = darkClassPattern.exec(source); while (match) { addFinding({ rule: "color.no_mobile_black_primary", severity: "error", file, line: lineNumber(source, match.index), message: "移动端主控件、卡片或底部导航不应使用黑色大块背景。", evidence: match[0], }); match = darkClassPattern.exec(source); } } function scanNativeFileInputs(file, source) { const fileInputPattern = /]*\btype=["']file["'][^>]*>/g; let match = fileInputPattern.exec(source); while (match) { if (/\bhidden\b/.test(match[0]) || /\bsr-only\b/.test(match[0])) { match = fileInputPattern.exec(source); continue; } addFinding({ rule: "form.no_native_file_input_visible", severity: "warning", file, line: lineNumber(source, match.index), message: "上传控件应提供中文按钮和文件名回显,避免暴露浏览器原生 Choose File 文案。", evidence: match[0].replace(/\s+/g, " ").slice(0, 140), }); match = fileInputPattern.exec(source); } } function scanMobileCapturePdfInputs(file, source) { const normalized = toPosix(path.relative(rootDir, file)); if (!/client\/src\/pages\/Mobile.*\.tsx$/.test(normalized)) { return; } const fileInputPattern = /]*\btype=["']file["'][^>]*>/g; let match = fileInputPattern.exec(source); while (match) { const inputSource = match[0]; if (/application\/pdf/i.test(inputSource) && /\bcapture\b/i.test(inputSource)) { addFinding({ rule: "form.no_pdf_capture_input", severity: "error", file, line: lineNumber(source, match.index), message: "PDF 上传入口不能带 capture,否则企业微信容易直接打开摄像头。", evidence: inputSource.replace(/\s+/g, " ").slice(0, 160), }); } match = fileInputPattern.exec(source); } } function scanUserFacingBusinessTerms(file, source) { const normalized = toPosix(path.relative(rootDir, file)); if (!userFacingBusinessTermFiles.has(normalized)) { return; } for (const term of forbiddenUserFacingBusinessTerms) { let start = source.indexOf(term); while (start !== -1) { addFinding({ rule: "business.no_internal_invoice_terms", severity: "error", file, line: lineNumber(source, start), message: `普通用户主流程不应暴露内部发票术语:${term}`, evidence: term, }); start = source.indexOf(term, start + term.length); } } } function scanRawBusinessIdentifiers(file, source) { const normalized = toPosix(file); if (!/Reimbursement|Invoice|Leave|Purchase|Travel/.test(path.basename(normalized))) { return; } const patterns = [ { regex: /formNumber\s*(?:\|\||\?\?)\s*(?:item|detail|reimbursement)\.id/g, message: "报销主展示不应在业务编号缺失时直接回退到 UUID。", }, { regex: /(?:报销|发票|请假|采购|差旅)ID/g, message: "面向用户的主信息不应使用内部 ID,应使用业务编号或摘要。", }, ]; for (const pattern of patterns) { let match = pattern.regex.exec(source); while (match) { addFinding({ rule: "data.no_uuid_primary_display", severity: "warning", file, line: lineNumber(source, match.index), message: pattern.message, evidence: match[0], }); match = pattern.regex.exec(source); } } } function scanReimbursementRawCodeDisplay(file, source) { const normalized = toPosix(path.relative(rootDir, file)); if (normalized !== "client/src/pages/ReimbursementDetailPage.tsx") { return; } const patterns = [ { regex: /状态:\{detail\.status\}/g, message: "PC 报销详情不应直接展示报销状态码,应显示中文业务状态。", }, { regex: /\{item\.status\}/g, message: "PC 报销详情不应直接展示发票状态码,应显示中文业务状态。", }, { regex: /expenseType\?\.code/g, message: "普通报销详情不应展示费用类型 code,应显示中文费用类型名。", }, { regex: /expenseType\?\.name\s*\?\?\s*item\.expenseTypeId/g, message: "普通报销详情不应在费用类型缺失时回退展示内部费用类型 ID。", }, { regex: /expenseType\?\.name\s*\?\?\s*linkedItem\.expenseTypeId/g, message: "普通报销详情的发票关联明细不应回退展示内部费用类型 ID。", }, { regex: /return\s+linkedItemId;/g, message: "普通报销详情的发票关联明细不应回退展示内部明细 ID。", }, { regex: /invoiceId\.slice\(0,\s*8\)/g, message: "普通报销详情的发票失败提示不应展示内部发票 ID 片段,应显示发票标题或用户可理解原因。", }, ]; for (const pattern of patterns) { let match = pattern.regex.exec(source); while (match) { addFinding({ rule: "business.no_raw_reimbursement_codes", severity: "error", file, line: lineNumber(source, match.index), message: pattern.message, evidence: match[0], }); match = pattern.regex.exec(source); } } } function extractStringList(source, name) { const assignment = new RegExp(`const\\s+${name}\\s*=\\s*\\[([\\s\\S]*?)\\];`, "m").exec(source); if (!assignment) { return []; } return Array.from(assignment[1].matchAll(/["']([^"']+)["']/g)).map((match) => match[1]); } function scanNavigationTitleCoverage(file, source) { if (!toPosix(file).endsWith("client/src/features/navigation/schema.ts")) { return; } const navPaths = new Set([ ...extractStringList(source, "MAIN_NAV_ORDER"), ...extractStringList(source, "ADMIN_NAV_ORDER"), ...extractStringList(source, "BACKUP_NAV_ORDER"), ]); const titlePaths = new Set(Array.from(source.matchAll(/\{\s*path:\s*["']([^"']+)["']/g)).map((match) => match[1])); for (const navPath of navPaths) { const covered = Array.from(titlePaths).some((titlePath) => navPath === titlePath || navPath.startsWith(`${titlePath}/`)); if (covered) { continue; } const index = source.indexOf(`"${navPath}"`); addFinding({ rule: "navigation.no_route_title_fallback", severity: "warning", file, line: index >= 0 ? lineNumber(source, index) : 1, message: `导航路径 ${navPath} 缺少 PAGE_TITLES 显式标题,可能在顶栏显示路由。`, evidence: navPath, }); } } function scanFile(file) { const source = readFileSync(file, "utf8"); scanForbiddenCopy(file, source); scanUserFacingBusinessTerms(file, source); scanMobileDarkControls(file, source); scanNativeFileInputs(file, source); scanMobileCapturePdfInputs(file, source); scanRawBusinessIdentifiers(file, source); scanReimbursementRawCodeDisplay(file, source); scanNavigationTitleCoverage(file, source); } const files = scanRoots.flatMap((scanRoot) => collectFiles(path.join(rootDir, scanRoot))); for (const file of files) { scanFile(file); } const summary = { scannedFiles: files.length, findings: findings.length, errors: findings.filter((finding) => finding.severity === "error").length, warnings: findings.filter((finding) => finding.severity === "warning").length, }; if (jsonOutput) { console.log(JSON.stringify({ summary, findings }, null, 2)); } else { console.log(`[ui-design-rules] scanned ${summary.scannedFiles} files, ${summary.findings} findings (${summary.errors} errors, ${summary.warnings} warnings)`); for (const finding of findings) { console.log(`${finding.severity.toUpperCase()} ${finding.rule} ${finding.file}:${finding.line} ${finding.message}`); if (finding.evidence) { console.log(` evidence: ${finding.evidence}`); } } if (!findings.length) { console.log("[ui-design-rules] no findings"); } if (findings.length && !strict) { console.log("[ui-design-rules] diagnostic mode: findings are reported but do not fail. Use --strict to enforce."); } } if (strict && findings.length > 0) { process.exitCode = 1; }