R100/A1-A6 建立产品设计规则基线

这个提交包含在:
2026-05-06 22:10:57 +08:00
父节点 3dfe0f83e0
当前提交 2a13fb7be5
共修改 4 个文件,包含 642 行新增和 0 行删除
+231
查看文件
@@ -0,0 +1,231 @@
#!/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) {
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;
}