From 2a13fb7be50855f83ba5cfc87ebe3409608babd3 Mon Sep 17 00:00:00 2001 From: shunli Date: Wed, 6 May 2026 22:10:57 +0800 Subject: [PATCH] =?UTF-8?q?R100/A1-A6=20=E5=BB=BA=E7=AB=8B=E4=BA=A7?= =?UTF-8?q?=E5=93=81=E8=AE=BE=E8=AE=A1=E8=A7=84=E5=88=99=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/tests/e2e/r100-design-baseline.spec.ts | 274 ++++++++++++++++++ docs/product-design-rules.md | 130 +++++++++ ...06_21-57-29-r100-design-rules-baseline.csv | 7 + scripts/qa/check-ui-design-rules.mjs | 231 +++++++++++++++ 4 files changed, 642 insertions(+) create mode 100644 client/tests/e2e/r100-design-baseline.spec.ts create mode 100644 docs/product-design-rules.md create mode 100644 issues/2026-05-06_21-57-29-r100-design-rules-baseline.csv create mode 100644 scripts/qa/check-ui-design-rules.mjs diff --git a/client/tests/e2e/r100-design-baseline.spec.ts b/client/tests/e2e/r100-design-baseline.spec.ts new file mode 100644 index 00000000..5782de7e --- /dev/null +++ b/client/tests/e2e/r100-design-baseline.spec.ts @@ -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: "employee@hyinda.com", + 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("employee@hyinda.com"); + 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"); + }); +}); diff --git a/docs/product-design-rules.md b/docs/product-design-rules.md new file mode 100644 index 00000000..39f183fd --- /dev/null +++ b/docs/product-design-rules.md @@ -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 审查并写清回滚路径。 diff --git a/issues/2026-05-06_21-57-29-r100-design-rules-baseline.csv b/issues/2026-05-06_21-57-29-r100-design-rules-baseline.csv new file mode 100644 index 00000000..bd2122fd --- /dev/null +++ b/issues/2026-05-06_21-57-29-r100-design-rules-baseline.csv @@ -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。" diff --git a/scripts/qa/check-ui-design-rules.mjs b/scripts/qa/check-ui-design-rules.mjs new file mode 100644 index 00000000..ed7d323b --- /dev/null +++ b/scripts/qa/check-ui-design-rules.mjs @@ -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 = /]*\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; +}