#!/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 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])) { 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 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 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); scanMobileDarkControls(file, source); scanNativeFileInputs(file, source); scanRawBusinessIdentifiers(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; }