R100/A1-A6 建立产品设计规则基线
这个提交包含在:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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。"
|
||||
|
@@ -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;
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户