237 行
6.9 KiB
JavaScript
237 行
6.9 KiB
JavaScript
#!/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 = /<input\b[^>]*\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;
|
|
}
|