From 28d86aff16f954cd18c0e46b86325a13fc6c0949 Mon Sep 17 00:00:00 2001 From: jeffusion Date: Thu, 19 Mar 2026 23:22:42 +0800 Subject: [PATCH] fix(ui): align card headers and stabilize themed layout polish --- .github/workflows/ci.yml | 18 + docs/design/ui-theme-language.md | 154 +++++++ frontend/.gitignore | 4 + frontend/bun.lock | 15 +- frontend/package.json | 6 +- frontend/playwright.config.ts | 43 ++ frontend/scripts/ui-regression.ts | 151 +++++++ frontend/src/App.tsx | 9 +- frontend/src/components/ConfigFieldInput.tsx | 16 +- frontend/src/components/ConfigGroupCard.tsx | 14 +- frontend/src/components/ConfigManager.tsx | 26 +- frontend/src/components/DataTable.tsx | 12 +- frontend/src/components/RepositoryManager.tsx | 44 +- .../src/components/RepositoryTableColumns.tsx | 8 +- frontend/src/components/ReviewConfigPage.tsx | 58 +-- .../src/components/WebhookToggleButton.tsx | 4 +- frontend/src/components/llm/ModelCombobox.tsx | 15 +- .../src/components/llm/ProviderDialog.tsx | 34 +- frontend/src/components/llm/ProviderList.tsx | 64 +-- .../src/components/llm/RoleAssignment.tsx | 50 +- .../src/components/llm/TestResultDialog.tsx | 40 +- frontend/src/components/ui/select.tsx | 11 +- frontend/src/hooks/useColorPalette.tsx | 58 +++ frontend/src/index.css | 426 +++++++++++++++++- frontend/src/pages/DashboardPage.tsx | 82 +++- frontend/src/pages/LoginPage.tsx | 28 +- frontend/tailwind.config.js | 16 + frontend/tests/visual/app.visual.spec.ts | 71 +++ .../config-dark-linux.png | Bin 0 -> 149137 bytes .../config-dark-nord-linux.png | Bin 0 -> 183738 bytes .../config-dark-tokyo-night-linux.png | Bin 0 -> 187688 bytes .../config-dark-zinc-linux.png | Bin 0 -> 134292 bytes .../config-light-linux.png | Bin 0 -> 148523 bytes .../config-light-nord-linux.png | Bin 0 -> 151888 bytes .../config-light-tokyo-night-linux.png | Bin 0 -> 154350 bytes .../config-light-zinc-linux.png | Bin 0 -> 116729 bytes .../login-dark-linux.png | Bin 0 -> 233924 bytes .../login-dark-nord-linux.png | Bin 0 -> 369133 bytes .../login-dark-tokyo-night-linux.png | Bin 0 -> 351181 bytes .../login-dark-zinc-linux.png | Bin 0 -> 161940 bytes .../login-light-linux.png | Bin 0 -> 272944 bytes .../login-light-nord-linux.png | Bin 0 -> 278255 bytes .../login-light-tokyo-night-linux.png | Bin 0 -> 289115 bytes .../login-light-zinc-linux.png | Bin 0 -> 162465 bytes .../repos-dark-linux.png | Bin 0 -> 158691 bytes .../repos-dark-nord-linux.png | Bin 0 -> 253112 bytes .../repos-dark-tokyo-night-linux.png | Bin 0 -> 249206 bytes .../repos-dark-zinc-linux.png | Bin 0 -> 131184 bytes .../repos-light-linux.png | Bin 0 -> 151731 bytes .../repos-light-nord-linux.png | Bin 0 -> 191324 bytes .../repos-light-tokyo-night-linux.png | Bin 0 -> 198819 bytes .../repos-light-zinc-linux.png | Bin 0 -> 98504 bytes .../review-config-dark-linux.png | Bin 0 -> 148281 bytes .../review-config-dark-nord-linux.png | Bin 0 -> 172191 bytes .../review-config-dark-tokyo-night-linux.png | Bin 0 -> 177292 bytes .../review-config-dark-zinc-linux.png | Bin 0 -> 133417 bytes .../review-config-light-linux.png | Bin 0 -> 146530 bytes .../review-config-light-nord-linux.png | Bin 0 -> 145761 bytes .../review-config-light-tokyo-night-linux.png | Bin 0 -> 149356 bytes .../review-config-light-zinc-linux.png | Bin 0 -> 116348 bytes frontend/tests/visual/fixtures/mockApi.ts | 335 ++++++++++++++ frontend/tests/visual/fixtures/screenshot.css | 24 + frontend/tests/visual/fixtures/stabilize.ts | 63 +++ frontend/vitest.config.ts | 3 +- package.json | 3 + 65 files changed, 1650 insertions(+), 255 deletions(-) create mode 100644 docs/design/ui-theme-language.md create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/scripts/ui-regression.ts create mode 100644 frontend/src/hooks/useColorPalette.tsx create mode 100644 frontend/tests/visual/app.visual.spec.ts create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/config-dark-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/config-dark-nord-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/config-dark-tokyo-night-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/config-dark-zinc-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/config-light-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/config-light-nord-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/config-light-tokyo-night-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/config-light-zinc-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/login-dark-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/login-dark-nord-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/login-dark-tokyo-night-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/login-dark-zinc-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/login-light-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/login-light-nord-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/login-light-tokyo-night-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/login-light-zinc-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/repos-dark-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/repos-dark-nord-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/repos-dark-tokyo-night-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/repos-dark-zinc-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/repos-light-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/repos-light-nord-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/repos-light-tokyo-night-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/repos-light-zinc-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/review-config-dark-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/review-config-dark-nord-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/review-config-dark-tokyo-night-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/review-config-dark-zinc-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/review-config-light-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/review-config-light-nord-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/review-config-light-tokyo-night-linux.png create mode 100644 frontend/tests/visual/app.visual.spec.ts-snapshots/review-config-light-zinc-linux.png create mode 100644 frontend/tests/visual/fixtures/mockApi.ts create mode 100644 frontend/tests/visual/fixtures/screenshot.css create mode 100644 frontend/tests/visual/fixtures/stabilize.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 838517f..0413019 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Install frontend dependencies + run: cd frontend && bun install --frozen-lockfile + - name: Lint run: bun run lint @@ -28,3 +31,18 @@ jobs: - name: Run tests run: bun test + + - name: Install Playwright Chromium + run: cd frontend && bunx playwright install --with-deps chromium + + - name: Run visual regression + run: bun run ui:visual + + - name: Upload Playwright artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-artifacts + path: | + frontend/playwright-report/ + frontend/test-results/ diff --git a/docs/design/ui-theme-language.md b/docs/design/ui-theme-language.md new file mode 100644 index 0000000..52d0b94 --- /dev/null +++ b/docs/design/ui-theme-language.md @@ -0,0 +1,154 @@ +# UI Theme Language(亮/暗双主题统一规范) + +## 目标 + +- 保证浅色/深色主题视觉一致、可读性稳定。 +- 避免组件直接写死颜色,防止后续开发样式漂移。 +- 让新增页面默认遵循同一套语义化设计语言。 + +## 三层设计语言模型 + +1. **Primitive(原子值)**:HSL 基础值,仅在全局 token 定义处出现。 +2. **Semantic(语义 token)**:`background`、`foreground`、`success`、`danger` 等,按语义命名。 +3. **Component(组件 token)**:组件只能消费语义 token,不允许跨层引用原子值。 + +## 当前项目的主题基线 + +主题定义文件:`frontend/src/index.css` + +- 基础语义:`background`、`foreground`、`card`、`muted`、`border`、`ring` +- 状态语义:`success`、`warning`、`danger`、`info` +- 补充语义:`surface-muted`、`surface-elevated`、`surface-overlay`、`text-subtle`、`text-soft`、`border-soft` + +Tailwind 语义映射:`frontend/tailwind.config.js` + +- 已将语义 token 映射为可直接使用的 class(如 `bg-success/10`、`text-danger`、`border-info/20`)。 + +### 主色方案(当前) + +- 主色选择:**Cobalt Blue(钴蓝)**,兼顾 light/dark 的对比度与品牌辨识度。 +- Light:`--primary: 224 76% 52%`,`--primary-foreground: 0 0% 100%` +- Dark:`--primary: 224 88% 68%`,`--primary-foreground: 224 40% 12%` +- 焦点环:`--ring` 与 `--primary` 保持同色,确保交互一致性。 +- 设计理由:亮色下避免过“脏”或偏绿感;暗色下提升明度保证可见性,同时用深色前景保证主按钮文字对比。 + +## 可选整套主题色方案(社区开源复用) + +> 要求:切换的是**整套语义 token**,不是只改 `primary`。 + +当前支持四套(统一冷调科技风): + +1. `cobalt`(默认) + - 本项目当前默认冷色科技风(自定义)。 +2. `zinc` + - 来源:shadcn/ui themes(MIT) + - 参考: +3. `nord` + - 来源:Nord(MIT) + - 参考: +4. `tokyo-night` + - 来源:Tokyo Night(Apache-2.0) + - 参考: + +实现方式: + +- `cobalt` 作为内置基础主题,直接由 `:root` / `.dark` 提供默认 token。 +- 其余方案(`zinc|nord|tokyo-night`)通过 `data-palette` 覆盖: + - `:root[data-palette='*']` 覆盖浅色 token + - `.dark[data-palette='*']` 覆盖暗色 token +- 在根节点写入 `data-palette`(`cobalt|zinc|nord|tokyo-night`)。 +- 组件侧不改业务 class,继续消费语义 token。 + +## 页面风格骨架(社区方案落地) + +> 目标:即使切换配色,页面结构、密度、层级、动效仍保持统一。 + +本项目采用了三类社区成熟范式,并映射到本仓库 utility: + +1. **4px 节奏与密度系统(shadcn/仪表盘实践)** + - 基础节奏按 4px 递进,主内容区使用 `theme-page-content`(统一宽度 + 留白节奏)。 + - 卡片内部与卡片间距默认采用 `p-6 / gap-6` 级别,避免页面“块状松散或拥挤”。 +2. **三层深度系统(卡片/悬浮/遮罩)** + - 统一卡片外观:`theme-card-shell` + `theme-card-header` + `theme-card-content`。 + - 交互抬升统一:`theme-interactive-elevate`(轻微位移 + 阴影,不做夸张动效)。 + - 页面壳层统一:`theme-shell-gradient` + `theme-sticky-bar`。 +3. **可控动效系统(Linear/Vercel 风格)** + - Hover/按钮反馈优先短时平滑动效,避免大幅动画导致“廉价感”。 + - 表单输入统一 `theme-input-surface`,状态条与统计胶囊统一 `theme-control-pill`。 + +参考来源: + +- shadcn/ui themes 与组件风格实践(MIT): +- Vercel Dashboard 设计迭代思路: +- Nord / Tokyo Night 社区配色体系: + - + - + +## 强制规则(必须遵守) + +1. **禁止在业务 TSX 中使用硬编码暗色类**:如 `bg-zinc-*`、`text-zinc-*`、`border-white/10`(历史 UI 基础组件逐步迁移,不作为新增业务代码例外)。 +2. **禁止在组件内写死颜色值**:如 `rgba(...)`、`#xxxxxx`、`rgb(...)`。 +3. **状态色统一语义化**:成功/警告/错误/信息统一用 `success|warning|danger|info`。 +4. **弹窗/卡片/表格优先使用语义表面色**:`card`、`muted`、`popover`、`background`。 +5. **交互阴影统一工具类**:`theme-glow-primary|success|warning|danger`。 +6. **普通 hover 反馈禁止用主色背景**:非主操作控件统一使用 `hover:bg-accent*` 或 `hover:bg-muted*`,避免亮色主题出现重色块。 + +## 推荐 class 使用方式 + +- 文字层级:`text-foreground` / `text-muted-foreground` +- 面板层级:`bg-card` / `bg-muted/50` / `bg-popover` +- 边框层级:`border-border` / `theme-border-soft` +- 状态展示:`text-success`、`bg-danger/10`、`border-warning/30` +- 普通交互 hover:`hover:bg-accent/60`、`hover:bg-accent`、`hover:bg-muted/60` +- 主操作 hover:仅主按钮可用 `hover:bg-primary/90` +- 顶部吸附操作栏:`theme-sticky-bar` +- 页面骨架:`theme-page-frame` / `theme-page-actions` / `theme-page-content` +- 卡片骨架:`theme-card-shell` / `theme-card-header` / `theme-card-content` +- 弹窗骨架:`theme-dialog-panel` / `theme-dialog-header` / `theme-dialog-body` / `theme-dialog-footer` +- 错误态容器:`theme-error-panel` +- 模态遮罩:`theme-surface-overlay` + +## 页面级统一约束(防止布局风格漂移) + +1. 页面容器优先使用 `theme-page-frame`,避免每个页面自行定义高度和底部间距。 +2. 顶部操作区统一使用 `theme-sticky-bar + theme-page-actions`,避免按钮栏视觉断层。 +3. 主内容区统一使用 `theme-page-content`,确保横向节奏和留白一致。 +4. 标准业务卡片统一使用 `theme-card-shell/header/content`,避免同类卡片出现不同边框/背景层级。 +5. 错误提示统一使用 `theme-error-panel`,保持状态反馈视觉语言一致。 + +## destructive 与 danger 的约定 + +- `destructive`:保留给 shadcn 组件内置 destructive 变体语义。 +- `danger`:业务状态语义(报错、失败、风险提示)统一使用。 +- 新业务组件优先使用 `danger`,避免 `destructive/danger` 混用造成漂移。 + +## 新功能开发检查清单 + +- [ ] 页面在 light/dark 下均可读(文本、边框、状态色有对比度) +- [ ] 无 `zinc/white` 等暗色硬编码 class +- [ ] 无内联 `style` 颜色值 +- [ ] 状态色全部使用语义 token +- [ ] 组件未绕过语义层直接访问原子颜色 +- [ ] `bun run ui:visual` 通过(light/dark 关键页面视觉回归) + +## 视觉基线截图回归(Playwright) + +- 生成/更新基线:`bun run ui:visual:update` +- 校验基线一致性:`bun run ui:visual` +- 轻量 UI 全链路:`bun run ui:regression && bun run ui:visual` + +约定: + +1. PR 默认运行 `ui:visual`,出现 diff 必须人工确认是“预期视觉变更”。 +2. 只有在确认设计变更成立时,才执行 `ui:visual:update` 更新基线并提交快照。 +3. 不允许在未更新设计规范的情况下大量更新视觉基线,避免把漂移“固化为正确”。 +4. 基线快照以 Linux CI 环境为准(当前为 `*-linux.png`),避免跨系统更新导致快照噪声。 + +## 迁移策略 + +当新增模块时,按以下顺序处理: + +1. 先补充语义 token(如确有新语义,而不是新颜色)。 +2. 在 Tailwind 映射语义 token。 +3. 在组件中只消费语义 class。 +4. 最后做 light/dark 视觉回归。 diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..3dca698 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -22,3 +22,7 @@ dist-ssr *.njsproj *.sln *.sw? + +# Playwright +playwright-report/ +test-results/ diff --git a/frontend/bun.lock b/frontend/bun.lock index cbea02e..affc1b2 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -26,6 +26,7 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", + "@playwright/test": "^1.56.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -201,6 +202,8 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, ""], + "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, ""], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, ""], @@ -729,6 +732,10 @@ "pirates": ["pirates@4.0.7", "", {}, ""], + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, ""], "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, ""], @@ -923,6 +930,8 @@ "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, ""], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "readdirp/picomatch": ["picomatch@2.3.1", "", {}, ""], @@ -933,8 +942,6 @@ "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, ""], - "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, ""], - "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, ""], "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, ""], @@ -947,10 +954,6 @@ "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, ""], - "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, ""], - "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, ""], - - "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, ""], } } diff --git a/frontend/package.json b/frontend/package.json index d1a581e..a88914c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,10 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "ui:regression": "bun run scripts/ui-regression.ts && vitest run src/components/llm/__tests__/ModelCombobox.test.tsx src/components/llm/__tests__/ProviderList.test.tsx src/components/llm/__tests__/RoleAssignment.test.tsx src/components/llm/__tests__/TestResultDialog.test.tsx", + "ui:visual": "playwright test -c playwright.config.ts", + "ui:visual:update": "playwright test -c playwright.config.ts --update-snapshots" }, "dependencies": { "@radix-ui/react-label": "^2.1.7", @@ -31,6 +34,7 @@ "tailwind-merge": "^3.3.1" }, "devDependencies": { + "@playwright/test": "^1.56.1", "@eslint/js": "^9.36.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..8e0e8bb --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,43 @@ +import { defineConfig, devices } from '@playwright/test'; + +const port = Number(process.env.PW_PORT ?? 4173); +const baseURL = process.env.PW_BASE_URL ?? `http://127.0.0.1:${port}`; + +export default defineConfig({ + testDir: './tests/visual', + timeout: 30_000, + forbidOnly: !!process.env.CI, + expect: { + timeout: 8_000, + toHaveScreenshot: { + animations: 'disabled', + caret: 'hide', + scale: 'css', + maxDiffPixels: 30, + stylePath: './tests/visual/fixtures/screenshot.css', + }, + }, + reporter: [ + ['list'], + ['html', { outputFolder: 'playwright-report', open: 'never' }], + ], + fullyParallel: false, + use: { + ...devices['Desktop Chrome'], + baseURL, + locale: 'zh-CN', + timezoneId: 'Asia/Shanghai', + viewport: { width: 1440, height: 900 }, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + webServer: process.env.PW_BASE_URL + ? undefined + : { + command: `bun run dev --host 127.0.0.1 --port ${port}`, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/frontend/scripts/ui-regression.ts b/frontend/scripts/ui-regression.ts new file mode 100644 index 0000000..3ceb34c --- /dev/null +++ b/frontend/scripts/ui-regression.ts @@ -0,0 +1,151 @@ +import { Glob } from 'bun'; +import { existsSync } from 'node:fs'; +import { relative, resolve } from 'node:path'; + +type Violation = { + file: string; + line: number; + reason: string; + snippet: string; +}; + +type Rule = { + reason: string; + regex: RegExp; +}; + +type Requirement = { + file: string; + reason: string; + needles: string[]; +}; + +const rules: Rule[] = [ + { reason: 'hardcoded zinc utility', regex: /\b(?:bg|text|border)-zinc-\d{2,3}\b/ }, + { reason: 'hardcoded white-alpha border', regex: /\bborder-white\/\d+\b/ }, + { reason: 'hardcoded black overlay', regex: /\bbg-black\/\d+\b/ }, + { reason: 'inline rgba color literal', regex: /rgba\(/ }, + { reason: 'inline rgb color literal', regex: /rgb\(/ }, + { reason: 'hex color literal', regex: /#[0-9a-fA-F]{3,8}\b/ }, + { reason: 'legacy red semantic marker', regex: /text-red-500/ }, + { reason: 'primary-tinted hover on non-primary surfaces', regex: /\b(?:hover:bg-primary\/(?:[1-8]0)|group-hover:bg-primary\/(?:[1-8]0))\b/ }, +]; + +const requirements: Requirement[] = [ + { + file: 'src/index.css', + reason: 'theme token and utility baseline', + needles: [ + '--success:', + '--warning:', + '--danger:', + '--info:', + '--surface-muted:', + '--surface-elevated:', + '--surface-overlay:', + '.theme-glow-primary', + '.theme-surface-overlay', + '.theme-sticky-bar', + '.theme-page-frame', + '.theme-page-actions', + '.theme-page-content', + '.theme-card-shell', + '.theme-card-header', + '.theme-card-content', + '.theme-error-panel', + '.theme-dialog-panel', + '.theme-dialog-header', + '.theme-dialog-body', + '.theme-dialog-footer', + ], + }, + { + file: 'tailwind.config.js', + reason: 'semantic color mapping in Tailwind', + needles: ['danger:', 'success:', 'warning:', 'info:'], + }, + { + file: 'src/components/llm/ProviderDialog.tsx', + reason: 'modal overlay must use semantic utility', + needles: ['theme-surface-overlay', 'theme-dialog-panel', 'theme-dialog-header', 'theme-dialog-body', 'theme-dialog-footer'], + }, + { + file: 'src/components/llm/TestResultDialog.tsx', + reason: 'modal overlay must use semantic utility', + needles: ['theme-surface-overlay', 'theme-dialog-panel', 'theme-dialog-header', 'theme-dialog-body', 'theme-dialog-footer'], + }, + { + file: '../docs/design/ui-theme-language.md', + reason: 'design language documentation baseline', + needles: ['强制规则', '页面级统一约束', 'destructive 与 danger 的约定', 'theme-surface-overlay'], + }, + { + file: 'src/components/ConfigManager.tsx', + reason: 'system config page should use unified page shell utilities', + needles: ['theme-page-frame', 'theme-page-actions', 'theme-page-content', 'theme-error-panel'], + }, + { + file: 'src/components/ReviewConfigPage.tsx', + reason: 'review config page should use unified page shell utilities', + needles: ['theme-page-frame', 'theme-page-actions', 'theme-page-content', 'theme-card-shell', 'theme-error-panel'], + }, +]; + +const violations: Violation[] = []; + +const addViolation = (file: string, line: number, reason: string, snippet: string) => { + violations.push({ file: relative(process.cwd(), file), line, reason, snippet }); +}; + +const scanSourceFiles = async () => { + const glob = new Glob('src/**/*.tsx'); + + for await (const file of glob.scan('.')) { + const absoluteFile = resolve(process.cwd(), file); + const content = await Bun.file(absoluteFile).text(); + const lines = content.split(/\r?\n/); + + lines.forEach((lineContent, index) => { + rules.forEach((rule) => { + if (rule.regex.test(lineContent)) { + addViolation(absoluteFile, index + 1, rule.reason, lineContent.trim()); + } + }); + }); + } +}; + +const verifyRequirements = async () => { + for (const requirement of requirements) { + const absoluteFile = resolve(process.cwd(), requirement.file); + + if (!existsSync(absoluteFile)) { + addViolation(absoluteFile, 1, requirement.reason, 'required file missing'); + continue; + } + + const content = await Bun.file(absoluteFile).text(); + requirement.needles.forEach((needle) => { + if (!content.includes(needle)) { + addViolation(absoluteFile, 1, requirement.reason, `missing token/pattern: ${needle}`); + } + }); + } +}; + +const printResult = () => { + if (violations.length === 0) { + console.log('✅ UI regression guard passed'); + return; + } + + console.error(`❌ UI regression guard failed with ${violations.length} issue(s):`); + violations.forEach((violation) => { + console.error(`- ${violation.file}:${violation.line} [${violation.reason}] ${violation.snippet}`); + }); + process.exit(1); +}; + +await scanSourceFiles(); +await verifyRequirements(); +printResult(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a3e54ea..03dee49 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { ConfigManager } from './components/ConfigManager'; import { ReviewConfigPage } from './components/ReviewConfigPage'; import { Toaster } from "@/components/ui/sonner" import { useTheme } from 'next-themes' +import { ColorPaletteProvider } from './hooks/useColorPalette'; function AuthGuard({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuth(); @@ -17,7 +18,7 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
-
+
INITIALIZING...
@@ -60,7 +61,11 @@ function AppContent() { } function App() { - return ; + return ( + + + + ); } export default App; diff --git a/frontend/src/components/ConfigFieldInput.tsx b/frontend/src/components/ConfigFieldInput.tsx index 4f96c8b..03fc46b 100644 --- a/frontend/src/components/ConfigFieldInput.tsx +++ b/frontend/src/components/ConfigFieldInput.tsx @@ -15,7 +15,7 @@ interface ConfigFieldInputProps { export function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputProps) { const renderInput = () => { - const baseInputClasses = "bg-zinc-900/50 border-white/10 focus-visible:ring-primary focus-visible:border-primary transition-all duration-200"; + const baseInputClasses = "bg-muted/50 border-border focus-visible:ring-primary focus-visible:border-primary transition-all duration-200"; switch (field.type) { case 'boolean': return ( @@ -34,9 +34,9 @@ export function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputPro - + {field.enumValues?.map((val) => ( - + {val} ))} @@ -82,22 +82,22 @@ export function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputPro const getSourceBadge = () => { switch (field.source) { case 'db': - return 已配置; + return 已配置; case 'default': default: - return 默认值; + return 默认值; } }; return ( -
+
- + {getSourceBadge()}
-
+
{field.description}
diff --git a/frontend/src/components/ConfigGroupCard.tsx b/frontend/src/components/ConfigGroupCard.tsx index a002709..0305f3e 100644 --- a/frontend/src/components/ConfigGroupCard.tsx +++ b/frontend/src/components/ConfigGroupCard.tsx @@ -51,20 +51,20 @@ export function ConfigGroupCard({ }; return ( - - + +
-
+
{(() => { const Icon = ICON_MAP[group.icon]; return Icon ? : {group.icon}; })()}
- + {group.label} - + {group.description}
@@ -75,14 +75,14 @@ export function ConfigGroupCard({ size="sm" onClick={handleReset} disabled={isResetting} - className="border-rose-500/30 text-rose-500 hover:bg-rose-500/10 hover:text-rose-400 hover:border-rose-500/50 transition-colors" + className="theme-interactive-elevate border-danger/30 text-danger hover:bg-danger/10 hover:text-danger hover:border-danger/50 transition-colors" > 重置组配置 )} - + {group.fields.map((field) => { const custom = renderField?.(field, localConfig[field.envKey], (val) => onFieldChange(field.envKey, val)); if (custom !== undefined) return {custom}; diff --git a/frontend/src/components/ConfigManager.tsx b/frontend/src/components/ConfigManager.tsx index 4b743df..69520d0 100644 --- a/frontend/src/components/ConfigManager.tsx +++ b/frontend/src/components/ConfigManager.tsx @@ -119,34 +119,34 @@ export function ConfigManager() { return (
- - + +
- - + +
); } if (isError) { return ( -
- +
+
加载配置失败: {error.message}
); } return ( -
+
{/* 固定在顶部的操作栏 */} -
-
+
+
-
+
{visibleGroups?.map((group) => ( ({ }) return ( -
+
- + {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { return ( - + {header.isPlaceholder ? null : flexRender( @@ -58,7 +58,7 @@ export function DataTable({ {row.getVisibleCells().map((cell) => ( @@ -69,9 +69,9 @@ export function DataTable({ )) ) : ( - +
-
+

未找到匹配的仓库

diff --git a/frontend/src/components/RepositoryManager.tsx b/frontend/src/components/RepositoryManager.tsx index 1298bb0..6768164 100644 --- a/frontend/src/components/RepositoryManager.tsx +++ b/frontend/src/components/RepositoryManager.tsx @@ -15,19 +15,19 @@ function DataTableSkeleton() { return (
- + - - - + + + {Array.from({ length: 10 }).map((_, i) => ( - - - + + + ))} @@ -60,31 +60,33 @@ export function RepositoryManager() { const totalPages = Math.ceil(totalCount / limit); return ( -
-
-
- +
+
+
+
+ setSearchTerm(e.target.value)} - className="pl-9 w-full sm:w-[300px] md:w-[250px] lg:w-[300px] bg-zinc-900/50 border-border/50 text-zinc-200 placeholder:text-zinc-600 focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:border-primary transition-all font-mono text-sm" + className="theme-input-surface pl-9 w-full sm:w-[300px] md:w-[250px] lg:w-[300px] focus-visible:ring-1 transition-all font-mono text-sm" /> +
{isLoading ? ( ) : isError ? ( -
-
-
+
+
+
-

System Error_

-

加载仓库列表失败: {error.message}

+

System Error_

+

加载仓库列表失败: {error.message}

@@ -93,8 +95,8 @@ export function RepositoryManager() { {totalPages > 1 && (
-
- 第 {page} 页 / 共 {totalPages}|{totalCount} 个仓库 +
+ 第 {page} 页 / 共 {totalPages}|{totalCount} 个仓库
@@ -105,7 +107,7 @@ export function RepositoryManager() { e.preventDefault(); setPage(p => Math.max(1, p - 1)); }} - className={`border border-border/50 hover:bg-zinc-800 hover:text-primary hover:border-primary/50 font-mono text-xs transition-colors ${page <= 1 ? "pointer-events-none opacity-30 bg-zinc-900/30" : "bg-zinc-900 text-zinc-400"}`} + className={`border border-border/50 hover:bg-accent hover:text-foreground hover:border-border/80 font-mono text-xs transition-colors ${page <= 1 ? "pointer-events-none opacity-30 bg-muted/40" : "bg-muted/70 text-muted-foreground"}`} /> @@ -115,7 +117,7 @@ export function RepositoryManager() { e.preventDefault(); setPage(p => Math.min(totalPages, p + 1)); }} - className={`border border-border/50 hover:bg-zinc-800 hover:text-primary hover:border-primary/50 font-mono text-xs transition-colors ${page >= totalPages ? "pointer-events-none opacity-30 bg-zinc-900/30" : "bg-zinc-900 text-zinc-400"}`} + className={`border border-border/50 hover:bg-accent hover:text-foreground hover:border-border/80 font-mono text-xs transition-colors ${page >= totalPages ? "pointer-events-none opacity-30 bg-muted/40" : "bg-muted/70 text-muted-foreground"}`} /> diff --git a/frontend/src/components/RepositoryTableColumns.tsx b/frontend/src/components/RepositoryTableColumns.tsx index bd0913f..108f2c8 100644 --- a/frontend/src/components/RepositoryTableColumns.tsx +++ b/frontend/src/components/RepositoryTableColumns.tsx @@ -8,7 +8,7 @@ export const columns: ColumnDef[] = [ { accessorKey: "name", header: "仓库名称", - cell: ({ row }) =>
{row.getValue("name")}
, + cell: ({ row }) =>
{row.getValue("name")}
, }, { accessorKey: "webhook_status", @@ -17,8 +17,8 @@ export const columns: ColumnDef[] = [ const status = row.getValue("webhook_status") as Repository["webhook_status"] const isActive = status === 'active' return ( -
- {isActive && } +
+ {isActive && } {isActive ? '已启用' : '未启用'}
) @@ -26,7 +26,7 @@ export const columns: ColumnDef[] = [ }, { id: "actions", - header: () =>
操作
, + header: () =>
操作
, cell: ({ row }) => { const repo = row.original return ( diff --git a/frontend/src/components/ReviewConfigPage.tsx b/frontend/src/components/ReviewConfigPage.tsx index b28a457..ae189bf 100644 --- a/frontend/src/components/ReviewConfigPage.tsx +++ b/frontend/src/components/ReviewConfigPage.tsx @@ -203,19 +203,19 @@ export function ReviewConfigPage() { return (
- - + +
- - + +
); } if (isError) { return ( -
- +
+
加载配置失败: {error.message}
); @@ -240,17 +240,17 @@ export function ReviewConfigPage() { if (field.envKey !== CODEX_MODEL_FIELD) return undefined; // Replicate ConfigFieldInput layout with ModelCombobox as the input control const sourceBadge = field.source === 'db' - ? 已配置 - : 默认值; + ? 已配置 + : 默认值; return ( -
+
- + {sourceBadge}
-
{field.description}
+
{field.description}
{field.envKey} @@ -272,15 +272,15 @@ export function ReviewConfigPage() { : undefined; return ( -
+
{/* Sticky action bar */} -
-
+
+
-
+
{/* Engine Selector Card */} - - + +
-
+
- 审查引擎 - 选择代码审查引擎模式 + 审查引擎 + 选择代码审查引擎模式
- +
{ENGINE_OPTIONS.map((opt) => ( ))} diff --git a/frontend/src/components/WebhookToggleButton.tsx b/frontend/src/components/WebhookToggleButton.tsx index 3b5f261..54ef190 100644 --- a/frontend/src/components/WebhookToggleButton.tsx +++ b/frontend/src/components/WebhookToggleButton.tsx @@ -37,8 +37,8 @@ export function WebhookToggleButton({ repoName, status, hookId }: WebhookToggleB size="sm" className={ status === 'active' - ? "border-rose-500/50 bg-transparent text-rose-500 hover:bg-rose-500/10 hover:text-rose-400 transition-colors" - : "bg-primary text-primary-foreground hover:bg-primary/90 transition-all duration-300 hover:shadow-[0_0_15px_rgba(45,212,191,0.5)] tech-glow" + ? "border-danger/50 bg-transparent text-danger hover:bg-danger/10 hover:text-danger transition-colors" + : "bg-primary text-primary-foreground hover:bg-primary/90 transition-all duration-300 tech-glow" } onClick={() => mutation.mutate()} disabled={mutation.isPending} diff --git a/frontend/src/components/llm/ModelCombobox.tsx b/frontend/src/components/llm/ModelCombobox.tsx index 21e5c36..a32bbfd 100644 --- a/frontend/src/components/llm/ModelCombobox.tsx +++ b/frontend/src/components/llm/ModelCombobox.tsx @@ -67,8 +67,8 @@ export function ModelCombobox({ const taggedModels = buildTaggedList(); const TAG_STYLES: Record = { - '推荐': 'bg-blue-500/15 text-blue-400', - '自定义': 'bg-amber-500/15 text-amber-400', + '推荐': 'bg-info/15 text-info', + '自定义': 'bg-warning/15 text-warning', }; useEffect(() => { @@ -104,22 +104,23 @@ export function ModelCombobox({ disabled={disabled} placeholder={placeholder} autoComplete="off" - className="bg-zinc-900 border-white/10 text-white w-full pr-10" + className="bg-muted/50 border-border text-foreground w-full pr-10" />
{isOpen && !disabled && taggedModels.length > 0 && ( -
+
{taggedModels.map((item, idx) => ( -
handleSelect(item.name)} > {item.name} {item.tag} -
+ ))}
diff --git a/frontend/src/components/llm/ProviderDialog.tsx b/frontend/src/components/llm/ProviderDialog.tsx index 950e725..0f4af49 100644 --- a/frontend/src/components/llm/ProviderDialog.tsx +++ b/frontend/src/components/llm/ProviderDialog.tsx @@ -91,25 +91,25 @@ function ProviderDialogInner({ onOpenChange, provider }: Omit -
-
-

{isEdit ? '编辑提供商' : '添加提供商'}

+
+
+
+

{isEdit ? '编辑提供商' : '添加提供商'}

-
+
- - setName(e.target.value)} placeholder="如: DeepSeek" autoComplete="off" className="bg-zinc-900 border-white/10 text-white" /> + + setName(e.target.value)} placeholder="如: DeepSeek" autoComplete="off" className="bg-muted/50 border-border text-foreground" />
- + setBaseUrl(e.target.value)} placeholder={isBaseUrlRequired ? "https://api.openai.com/v1" : "留空以使用默认地址"} autoComplete="off" - className="bg-zinc-900 border-white/10 text-white" + className="bg-muted/50 border-border text-foreground" />
- +
- + setApiKeyInput(e.target.value)} placeholder={isEdit && provider?.hasKey ? '•••••••• (输入以覆盖)' : 'sk-...'} autoComplete="off" - className="bg-zinc-900 border-white/10 text-white" + className="bg-muted/50 border-border text-foreground" />
-
- @@ -152,20 +152,20 @@ export function ProviderList() {
- - - 名称 - 类型 - 默认模型 - 状态 - 启用 - 操作 + + + 名称 + 类型 + 默认模型 + 状态 + 启用 + 操作 {isLoading ? ( - - + +
加载中... @@ -173,31 +173,31 @@ export function ProviderList() { ) : providers.length === 0 ? ( - - + + 暂无提供商配置,请点击右上角添加。 ) : ( providers.map(provider => ( - - + + {provider.name} - + {TYPE_LABELS[provider.type] || provider.type} - - + + {provider.defaultModel}
- - {provider.hasKey ? '就绪' : '无 Key'} + + {provider.hasKey ? '就绪' : '无 Key'}
@@ -213,7 +213,7 @@ export function ProviderList() { size="icon" onClick={() => handleTest(provider)} disabled={testingId === provider.id || !provider.hasKey} - className="h-8 w-8 text-zinc-400 hover:text-primary hover:bg-primary/10 transition-colors" + className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors" title="测试连接" > {testingId === provider.id ? ( @@ -226,7 +226,7 @@ export function ProviderList() { variant="ghost" size="icon" onClick={() => openEdit(provider)} - className="h-8 w-8 text-zinc-400 hover:text-white hover:bg-white/10 transition-colors" + className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors" title="编辑" > @@ -235,7 +235,7 @@ export function ProviderList() { variant="ghost" size="icon" onClick={() => handleDelete(provider)} - className="h-8 w-8 text-zinc-400 hover:text-red-400 hover:bg-red-500/10 transition-colors" + className="h-8 w-8 text-muted-foreground hover:text-danger hover:bg-danger/10 transition-colors" title="删除" > diff --git a/frontend/src/components/llm/RoleAssignment.tsx b/frontend/src/components/llm/RoleAssignment.tsx index 3a0af72..552b0d0 100644 --- a/frontend/src/components/llm/RoleAssignment.tsx +++ b/frontend/src/components/llm/RoleAssignment.tsx @@ -112,65 +112,65 @@ export function RoleAssignment() { const enabledProviders = providers.filter(p => p.isEnabled && p.hasKey); return ( - - + +
-
- +
+
-
- - 角色分配 - - - 为 AI 审查系统的不同角色指定提供商和模型 - +
+ + 角色分配 + + + 为 AI 审查系统的不同角色指定提供商和模型 + +
-
- + {isLoading ? ( -
+
加载角色配置...
) : ( -
+
{ROLES.map(role => { const state = roleStates[role] || { providerId: null, model: '' }; const isDirty = roles.find(r => r.role === role)?.providerId !== state.providerId || (roles.find(r => r.role === role)?.model || '') !== state.model; return ( -
+
-
- + { + if (isColorPalette(value)) { + setPalette(value); + } + }} + > + + + + + Cobalt Blue + Zinc Neutral + Nord + Tokyo Night + + +