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

这个提交包含在:
2026-05-06 22:10:57 +08:00
父节点 3dfe0f83e0
当前提交 2a13fb7be5
共修改 4 个文件,包含 642 行新增和 0 行删除
@@ -0,0 +1,274 @@
import { expect, test, type Page, type Route, type TestInfo } from "@playwright/test";
function envelope(data: unknown) {
return { requestId: "mock-r100-design", data };
}
async function fulfillJson(route: Route, data: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(envelope(data)),
});
}
const me = {
id: "employee-r100",
employeeNo: "E100",
name: "Employee",
phone: "13800000000",
email: "[email protected]",
departmentId: "dept-r100",
directManagerId: null,
status: "active",
notificationPushEnabled: true,
roleCodes: ["manager"],
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: true,
hasAvatar: false,
hasIdNumber: false,
score: 50,
},
wecomBinding: null,
};
function navigationItem(key: string, href: string, label: string, icon: string) {
return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null };
}
async function mockDesignApis(page: Page) {
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const url = new URL(route.request().url());
const path = url.pathname;
if (path === "/api/auth/sso/providers") {
await fulfillJson(route, []);
return;
}
if (path === "/api/auth/wecom/config") {
await fulfillJson(route, {
enabled: true,
entryEnabled: true,
oauthEnabled: true,
corpId: "ww-r100",
agentId: "1000002",
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
bindEnabled: true,
defaultReturnTo: "/mobile",
status: "ready",
statusReasons: [],
});
return;
}
if (path === "/api/auth/login" || path === "/api/auth/refresh") {
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
body: JSON.stringify(envelope({ accessToken: "mock-r100-token", expiresIn: 900, mustChangePassword: false })),
});
return;
}
if (path === "/api/me") {
await fulfillJson(route, me);
return;
}
if (path === "/api/system/navigation") {
await fulfillJson(route, {
workspace: {
id: "workspace-r100",
persisted: true,
profile: "small_team_internal",
orgMode: "single_company",
primaryEntryMode: "password",
notificationStrategy: "in_app",
devicePriority: "mobile",
moduleVisibility: {},
createdAt: null,
updatedAt: null,
updatedBy: null,
},
sections: {
main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
navigationItem("leave", "/leave-requests", "请假", "event_available"),
navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"),
],
admin: [],
backup: [],
},
recommendedRolePresets: [],
});
return;
}
if (path === "/api/portal/home") {
await fulfillJson(route, {
workspace: { id: "workspace-r100", persisted: true, profile: "small_team_internal" },
greeting: { name: me.name },
cards: [
{ key: "pending_tasks", title: "待处理", value: 2, href: "/workflow/tasks", severity: "warning" },
{ key: "my_applications", title: "我的申请", value: 1, href: "/reimbursements", severity: "normal" },
],
pendingTasks: { count: 2, href: "/workflow/tasks" },
myApplications: [
{ domain: "reimbursement", count: 1, href: "/reimbursements" },
{ domain: "leave", count: 0, href: "/leave-requests" },
],
riskSummary: null,
announcements: [{ id: "ann-r100", title: "试运行提醒", summary: "今天处理待办。", href: null }],
quickLinks: [{ key: "capture", title: "上传发票", href: "/mobile/capture" }],
todayPresence: { date: "2026-05-06", status: "office", label: "在岗", note: null, href: "/team" },
teamPresence: null,
weeklyReport: { weekStart: "2026-05-04", status: "draft", needsHelp: false, href: "/weekly-reports" },
weeklySummary: null,
todoSummary: { openCount: 2, highPriorityCount: 0, href: "/todos" },
projectSummary: null,
peopleLifecycle: null,
adoptionSummary: null,
recentActivities: [],
teamStatusBoard: [],
});
return;
}
if (path === "/api/attendance/me/today") {
await fulfillJson(route, {
workDate: "2026-05-06",
needClockIn: true,
exemptionReason: null,
item: null,
});
return;
}
if (path.startsWith("/api/employees/") && path.endsWith("/profile")) {
await fulfillJson(route, {
employee: {
...me,
avatarUrl: null,
department: { id: "dept-r100", name: "研发部" },
manager: null,
},
});
return;
}
if (path === "/api/workflow/tasks") {
await fulfillJson(route, {
items: [],
total: 0,
page: 1,
pageSize: 20,
});
return;
}
if (path === "/api/notifications") {
await fulfillJson(route, {
items: [],
total: 0,
page: 1,
pageSize: 5,
unreadCount: 0,
});
return;
}
await fulfillJson(route, {});
});
}
async function login(page: Page) {
await page.goto("/login");
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("EmployeePassword1234");
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/portal/);
}
async function collectDesignDiagnostics(page: Page) {
return page.evaluate(() => {
function isVisible(element: Element) {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
const bodyText = document.body.innerText;
const badCopy = ["先处理这几件事", "清爽"].filter((phrase) => bodyText.includes(phrase));
const routeLikeText = bodyText
.split("\n")
.map((line) => line.trim())
.filter((line) => /^\/[a-z0-9-]+/.test(line))
.slice(0, 10);
const visibleTables = Array.from(document.querySelectorAll("table")).filter(isVisible).length;
const darkControls = Array.from(document.querySelectorAll("a, button, [role='button'], [role='tab']"))
.filter(isVisible)
.map((element) => {
const styles = window.getComputedStyle(element);
return {
text: (element.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 40),
backgroundColor: styles.backgroundColor,
color: styles.color,
};
})
.filter((item) => {
const darkBackgrounds = new Set(["rgb(0, 0, 0)", "rgb(24, 24, 27)", "rgb(15, 23, 42)"]);
return darkBackgrounds.has(item.backgroundColor) && item.color === "rgb(255, 255, 255)";
});
return {
url: window.location.pathname,
badCopy,
routeLikeText,
visibleTables,
darkControls,
};
});
}
async function attachDiagnostics(page: Page, testInfo: TestInfo, name: string) {
const diagnostics = await collectDesignDiagnostics(page);
await testInfo.attach(`${name}.json`, {
body: JSON.stringify(diagnostics, null, 2),
contentType: "application/json",
});
await testInfo.attach(`${name}.png`, {
body: await page.screenshot({ fullPage: true }),
contentType: "image/png",
});
}
test.describe("R100 design baseline diagnostics", () => {
test("手机首页和我的页可生成视觉诊断", async ({ page }, testInfo) => {
await mockDesignApis(page);
await page.setViewportSize({ width: 390, height: 844 });
await login(page);
await page.goto("/mobile");
await expect(page.getByRole("heading", { name: "今天" })).toBeVisible();
await expect(page.getByTestId("mobile-today-focus")).toBeVisible();
await attachDiagnostics(page, testInfo, "r100-mobile-home");
await page.goto("/mobile/me");
await expect(page.getByRole("heading", { name: "我的" })).toBeVisible();
await expect(page.getByTestId("mobile-more-links")).toBeVisible();
await attachDiagnostics(page, testInfo, "r100-mobile-me");
});
test("PC 工作台可生成视觉诊断", async ({ page }, testInfo) => {
await mockDesignApis(page);
await page.setViewportSize({ width: 1440, height: 960 });
await login(page);
await expect(page.getByTestId("topbar-profile-trigger")).toBeVisible();
await expect(page.locator("aside")).toBeVisible();
await attachDiagnostics(page, testInfo, "r100-pc-portal");
});
});
+130
查看文件
@@ -0,0 +1,130 @@
# 产品设计规则基线
本文件是 R100 之后 PC 网页和企业微信/手机网页统一优化的事实源。目标用户是 10 人以下的国内科技公司:使用频次高、角色重叠、审批链短,系统应当像工作台,不像宣传站,也不应像重型 ERP。
## 设计目标
- 首屏只放今天最该处理的事,避免解释系统功能。
- PC 端服务批量查看、对账、审批和管理;手机端服务提交、补资料、查状态和处理待办。
- 页面文案使用国内 OA 常见表达:待处理、草稿、审批中、已通过、已支付、已驳回、提交、保存、删除、撤回。
- 所有业务对象使用业务编号或摘要展示,不把 UUID 当作主编号展示给用户。
- 颜色以浅色工作台为主,黑色只用于正文或弱边框,不作为移动主按钮、底部导航、首屏卡片背景。
## 文案规则
禁止:
- 口语化标题,例如“先处理这几件事”。
- 内部评价词,例如“清爽”。
- 对用户解释设计意图,例如“低频模块放在这里,避免干扰移动首页”。
- 路由式标题,例如直接显示 `/invoice-pool`。
- 长段功能说明占据表单、弹窗、列表首屏。
推荐:
- 首页模块标题用“待处理”“常用”“我的申请”“通知”。
- 操作按钮用“新建报销”“继续编辑”“提交审批”“删除草稿”“上传发票”。
- 空状态只说明现状和下一步,例如“暂无草稿”“暂无待处理审批”。
- 表单说明放在字段错误、帮助文本或 hover/折叠信息里,不放在首屏大段文案。
## 色彩规则
- 主色:品牌蓝/青色系,用于主按钮、选中态、链接和轻量提示。
- 中性色:白色、浅灰、浅蓝灰用于背景、卡片和分隔。
- 状态色:红色表示危险或失败,橙色表示待处理,绿色表示完成。
- 禁止移动端主卡、底部导航选中态和核心 CTA 使用黑色大块背景。
- 卡片圆角保持 8px 左右;移动端可使用 12px 到 16px,但不做营销式大圆角堆叠。
## PC 页面模式
列表页:
- 顶部只保留标题、核心筛选、主操作。
- 表格列优先显示业务编号、申请人、金额/天数、状态、更新时间、操作。
- 操作列至少区分“查看/编辑”和状态允许时的“删除草稿”。
- 长 ID、调试字段、路径字段默认不进入主列。
明细页:
- 标题显示业务编号或摘要,例如“报销单 BX20260506001”。
- 状态、金额、申请人、更新时间作为摘要条。
- 主流程操作固定在页头或底部操作区,危险操作靠右或二次确认。
- 审批流、附件、发票和日志分区展示,避免混在一个长表单里。
表单和弹窗:
- 表单先收集必要字段,非必要字段折叠或后置。
- 发票、附件上传使用自定义中文控件,不暴露浏览器原生 `Choose File` 文案。
- 弹窗只承担单一任务,不在弹窗里解释整个业务流程。
## 手机和企业微信页面模式
- 手机端默认隐藏 PC 侧边栏和顶栏,使用移动首页、底部导航和轻量卡片。
- 菜单不应占用大量空间;常用入口放底部导航,更多入口放“我的”。
- 列表使用卡片,不直接显示桌面表格。
- 表单字段纵向排列,按钮宽度稳定,首屏优先完成“新建/继续编辑/提交”。
- 企业微信入口应尽量免登录;绑定失败或权限不足时显示明确下一步,而不是让用户反复登录。
## 报销专项规则
报销是下一批 R101 的主流程,不在 R100 直接改数据模型。R101 必须单独处理:
- 后端生成业务编号,例如 `BX20260506001`,前端不再用 UUID 作为主展示。
- 草稿允许删除;审批中使用撤回;已通过和已支付不提供删除。
- 报销明细存在且只有一条时,上传发票默认绑定该明细。
- 多条明细时优先按金额匹配;无法匹配时允许用户选择明细或绑定整单。
- 发票上传支持批量,OCR 显示明确状态和可重试入口,不长期停留在“上传中/处理中”。
- PC 端适合完整明细和对账;手机端适合拍照上传、补充说明、继续编辑草稿和查状态。
## 请假、票据、采购、差旅规则
- 请假手机端应提供新建、编辑草稿、提交和查看记录,不显示桌面表格。
- 票据中心应显示票据号码、销售方、金额、OCR 状态和绑定状态,不把内部 ID 放在主位置。
- 采购和差旅页使用和报销一致的列表、状态、操作区和移动卡片规则。
- 所有上传控件都使用中文按钮和文件名回显。
## R99 WIP 处置
当前工作区存在 R99 企业微信移动端整改 WIP,涉及:
- `client/src/pages/MobilePortalPage.tsx`
- `client/src/pages/MobileMePage.tsx`
- `client/src/components/mobile/MobileBottomNav.tsx`
- `client/src/layouts/AppShellLayout.tsx`
- `client/src/pages/MobileReimbursementsPage.tsx`
- `client/src/pages/MobileLeavePage.tsx`
- `client/src/router/index.tsx`
- `client/src/router/lazy/index.ts`
这些改动方向与本设计基线基本一致,但仍需要在 R103 中统一吸收或经批准清理。本 R100 批次不把这些 WIP 当作完成项提交,避免和设计规则、报销重构混在一起。
## 后续批次
R101 报销主流程重构:
- 后端业务编号、权限和删除规则。
- 报销列表、明细页、发票绑定和草稿编辑体验。
- PC 与手机报销流程的自动化回归。
- 需要 reviewer 检查权限、状态机和回滚策略。
R102 跨页面一致性:
- 票据、请假、采购、差旅页面统一列表、表单、上传和状态文案。
- 清理路径式标题、原生上传控件和 UUID 主展示。
- 补齐页面级 Playwright 视觉诊断。
R103 企业微信/手机端统一清理:
- 吸收或清理 R99 WIP。
- 固定移动首页、我的、报销、请假、审批、票据上传的移动页面。
- 手机端不再复用桌面表格作为主要体验。
## 验收规则
每个后续批次至少满足:
- Issue CSV 中明确业务流程级 `Test_Method`。
- `node scripts/qa/check-ui-design-rules.mjs` 可运行并记录问题。
- 前端改动执行 `cd client && npm run typecheck` 和 `cd client && npm run build`。
- 涉及移动端或视觉问题时,补充 Playwright 截图或视觉诊断。
- 涉及状态机、权限、数据迁移时,单独 reviewer 审查并写清回滚路径。
@@ -0,0 +1,7 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
A1,R100 执行合同与 WIP 边界,"将过大的页面统一优化方案收敛为设计基线批次;明确 R99 移动端 WIP 先登记不混入本批业务重构。","存在本 CSV;R99 WIP 在设计规则文档中被列为待吸收/待清理,不直接作为本批完成项。","验证 CSV 格式:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-06_21-57-29-r100-design-rules-baseline.csv;业务流程影响:不改变运行时流程,仅固定后续迭代合同。","manual|python",DONE,DONE,DONE,"issues/2026-05-06_21-57-29-r100-design-rules-baseline.csv","[]","不提交密钥;R99 WIP 不回滚,后续在 R103 吸收或经批准清理。"
A2,统一设计规则基线,"面向 10 人以下国内科技公司,固定 PC 与企业微信/手机端的文案、色彩、导航、表单、列表、明细、上传、业务编号和移动页面模式。","docs/product-design-rules.md 存在;包含禁止项、推荐页面模板、移动/PC 差异、报销后续重构原则和验收规则。","人工阅读 docs/product-design-rules.md;业务流程影响:不直接改变用户流程,但为报销、请假、票据、采购、差旅页面统一改造提供验收依据。","manual",DONE,DONE,DONE,"docs/product-design-rules.md","[""A1""]","设计规则作为 R101-R103 的事实源;如规则导致产品取舍变化,后续 Issue 单独批准。"
A3,静态 UI 诊断脚本,"新增可重复运行的 UI 规则诊断脚本,扫描啰嗦文案、黑色移动主控件、路由式标题、原生文件上传、UUID 主展示等问题。","scripts/qa/check-ui-design-rules.mjs 可运行;默认输出当前问题但不阻断;--strict 模式可作为后续门禁。","node scripts/qa/check-ui-design-rules.mjs;业务流程影响:覆盖登录、Portal、报销、请假、票据等页面源码的设计规则静态诊断,不访问生产数据。","node",DONE,DONE,DONE,"scripts/qa/check-ui-design-rules.mjs","[""A2""]","默认非阻断以记录现状;后续 R101/R102/R103 清零后启用 --strict。"
A4,视觉与移动诊断基线,"新增 Playwright 级设计基线用例,记录移动首页、我的页和 PC 工作台的文案、深色控件、路径式标题和桌面表格诊断。","存在可单独运行的 R100 Playwright 诊断用例;生成截图和 JSON 附件;用例覆盖 390px 移动视口和 PC 视口关键入口。","cd client && npx playwright test tests/e2e/r100-design-baseline.spec.ts --reporter=list;业务流程影响:模拟登录后访问移动首页、我的页和 PC 工作台,确认核心入口可打开并生成视觉诊断证据。","playwright",DONE,DONE,DONE,"client/tests/e2e/r100-design-baseline.spec.ts","[""A2"",""A3""]","使用 mock API,不依赖真实企业微信或生产账号;本批诊断不阻断当前已知问题,后续 R101-R103 清零后再改为严格门禁。"
A5,R101-R103 后续拆分方案,"把报销主流程、跨页面一致性、企业微信/手机端统一清理拆成后续批次,避免 R100 一次性改动过大。","docs/product-design-rules.md 包含 R101/R102/R103 的目标、验收重点、风险与回滚边界;不会直接修改报销编号、删除或审批状态机。","人工阅读 docs/product-design-rules.md 的后续路线章节;业务流程影响:规划报销草稿删除、业务编号、发票绑定、移动端统一入口等后续实施顺序。","manual",DONE,DONE,DONE,"docs/product-design-rules.md","[""A2""]","R101 涉及后端编号与权限,必须单独 Issue CSV 和 reviewer 审查。"
A6,验证回填与中文提交,"执行 CSV 校验、静态诊断、前端类型检查/构建或说明未执行原因,并按中文提交 R100 基线变更。","A1-A5 状态按实际验证结果回填;git commit message 为中文且引用 R100/A1-A6。","python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-06_21-57-29-r100-design-rules-baseline.csv;node scripts/qa/check-ui-design-rules.mjs;cd client && npm run typecheck;cd client && npm run build;业务流程影响:确认本批基线不破坏前端构建。","python|node|npm|git",DONE,DONE,DONE,"issues/2026-05-06_21-57-29-r100-design-rules-baseline.csv|docs/product-design-rules.md|scripts/qa/check-ui-design-rules.mjs|client/tests/e2e/r100-design-baseline.spec.ts","[""A1"",""A2"",""A3"",""A4"",""A5""]","提交时只纳入本批文件,避免混入既有 R99 WIP。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 A1 R100 执行合同与 WIP 边界 将过大的页面统一优化方案收敛为设计基线批次;明确 R99 移动端 WIP 先登记不混入本批业务重构。 存在本 CSV;R99 WIP 在设计规则文档中被列为待吸收/待清理,不直接作为本批完成项。 验证 CSV 格式:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-06_21-57-29-r100-design-rules-baseline.csv;业务流程影响:不改变运行时流程,仅固定后续迭代合同。 manual|python DONE DONE DONE issues/2026-05-06_21-57-29-r100-design-rules-baseline.csv [] 不提交密钥;R99 WIP 不回滚,后续在 R103 吸收或经批准清理。
3 A2 统一设计规则基线 面向 10 人以下国内科技公司,固定 PC 与企业微信/手机端的文案、色彩、导航、表单、列表、明细、上传、业务编号和移动页面模式。 docs/product-design-rules.md 存在;包含禁止项、推荐页面模板、移动/PC 差异、报销后续重构原则和验收规则。 人工阅读 docs/product-design-rules.md;业务流程影响:不直接改变用户流程,但为报销、请假、票据、采购、差旅页面统一改造提供验收依据。 manual DONE DONE DONE docs/product-design-rules.md ["A1"] 设计规则作为 R101-R103 的事实源;如规则导致产品取舍变化,后续 Issue 单独批准。
4 A3 静态 UI 诊断脚本 新增可重复运行的 UI 规则诊断脚本,扫描啰嗦文案、黑色移动主控件、路由式标题、原生文件上传、UUID 主展示等问题。 scripts/qa/check-ui-design-rules.mjs 可运行;默认输出当前问题但不阻断;--strict 模式可作为后续门禁。 node scripts/qa/check-ui-design-rules.mjs;业务流程影响:覆盖登录、Portal、报销、请假、票据等页面源码的设计规则静态诊断,不访问生产数据。 node DONE DONE DONE scripts/qa/check-ui-design-rules.mjs ["A2"] 默认非阻断以记录现状;后续 R101/R102/R103 清零后启用 --strict。
5 A4 视觉与移动诊断基线 新增 Playwright 级设计基线用例,记录移动首页、我的页和 PC 工作台的文案、深色控件、路径式标题和桌面表格诊断。 存在可单独运行的 R100 Playwright 诊断用例;生成截图和 JSON 附件;用例覆盖 390px 移动视口和 PC 视口关键入口。 cd client && npx playwright test tests/e2e/r100-design-baseline.spec.ts --reporter=list;业务流程影响:模拟登录后访问移动首页、我的页和 PC 工作台,确认核心入口可打开并生成视觉诊断证据。 playwright DONE DONE DONE client/tests/e2e/r100-design-baseline.spec.ts ["A2","A3"] 使用 mock API,不依赖真实企业微信或生产账号;本批诊断不阻断当前已知问题,后续 R101-R103 清零后再改为严格门禁。
6 A5 R101-R103 后续拆分方案 把报销主流程、跨页面一致性、企业微信/手机端统一清理拆成后续批次,避免 R100 一次性改动过大。 docs/product-design-rules.md 包含 R101/R102/R103 的目标、验收重点、风险与回滚边界;不会直接修改报销编号、删除或审批状态机。 人工阅读 docs/product-design-rules.md 的后续路线章节;业务流程影响:规划报销草稿删除、业务编号、发票绑定、移动端统一入口等后续实施顺序。 manual DONE DONE DONE docs/product-design-rules.md ["A2"] R101 涉及后端编号与权限,必须单独 Issue CSV 和 reviewer 审查。
7 A6 验证回填与中文提交 执行 CSV 校验、静态诊断、前端类型检查/构建或说明未执行原因,并按中文提交 R100 基线变更。 A1-A5 状态按实际验证结果回填;git commit message 为中文且引用 R100/A1-A6。 python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-06_21-57-29-r100-design-rules-baseline.csv;node scripts/qa/check-ui-design-rules.mjs;cd client && npm run typecheck;cd client && npm run build;业务流程影响:确认本批基线不破坏前端构建。 python|node|npm|git DONE DONE DONE issues/2026-05-06_21-57-29-r100-design-rules-baseline.csv|docs/product-design-rules.md|scripts/qa/check-ui-design-rules.mjs|client/tests/e2e/r100-design-baseline.spec.ts ["A1","A2","A3","A4","A5"] 提交时只纳入本批文件,避免混入既有 R99 WIP。
+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;
}