fix(ui): align card headers and stabilize themed layout polish
18
.github/workflows/ci.yml
vendored
@@ -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/
|
||||
|
||||
154
docs/design/ui-theme-language.md
Normal file
@@ -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)
|
||||
- 参考:<https://github.com/shadcn-ui/ui/blob/main/apps/v4/public/r/themes.css>
|
||||
3. `nord`
|
||||
- 来源:Nord(MIT)
|
||||
- 参考:<https://github.com/nordtheme/nord>
|
||||
4. `tokyo-night`
|
||||
- 来源:Tokyo Night(Apache-2.0)
|
||||
- 参考:<https://github.com/folke/tokyonight.nvim>
|
||||
|
||||
实现方式:
|
||||
|
||||
- `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):<https://github.com/shadcn-ui/ui>
|
||||
- Vercel Dashboard 设计迭代思路:<https://vercel.com/changelog/dashboard-navigation-redesign-rollout>
|
||||
- Nord / Tokyo Night 社区配色体系:
|
||||
- <https://github.com/nordtheme/nord>
|
||||
- <https://github.com/folke/tokyonight.nvim>
|
||||
|
||||
## 强制规则(必须遵守)
|
||||
|
||||
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 视觉回归。
|
||||
4
frontend/.gitignore
vendored
@@ -22,3 +22,7 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Playwright
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
@@ -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", "", {}, ""],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
43
frontend/playwright.config.ts
Normal file
@@ -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,
|
||||
},
|
||||
});
|
||||
151
frontend/scripts/ui-regression.ts
Normal file
@@ -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();
|
||||
@@ -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 }) {
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="relative flex h-12 w-12 items-center justify-center">
|
||||
<div className="absolute h-full w-full rounded-full border-b-2 border-primary animate-spin"></div>
|
||||
<div className="absolute h-8 w-8 rounded-full border-t-2 border-primary/50 animate-spin opacity-50" style={{ animationDirection: 'reverse', animationDuration: '1.5s' }}></div>
|
||||
<div className="absolute h-8 w-8 rounded-full border-t-2 border-primary/50 opacity-50 theme-spin-reverse-slow"></div>
|
||||
<div className="h-2 w-2 rounded-full bg-primary animate-pulse"></div>
|
||||
</div>
|
||||
<div className="text-sm font-mono tracking-widest text-primary/80 animate-pulse">INITIALIZING...</div>
|
||||
@@ -60,7 +61,11 @@ function AppContent() {
|
||||
}
|
||||
|
||||
function App() {
|
||||
return <AppContent />;
|
||||
return (
|
||||
<ColorPaletteProvider>
|
||||
<AppContent />
|
||||
</ColorPaletteProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -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
|
||||
<SelectTrigger className={`w-full ${baseInputClasses}`}>
|
||||
<SelectValue placeholder="请选择..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-zinc-950 border-white/10">
|
||||
<SelectContent className="bg-popover border-border">
|
||||
{field.enumValues?.map((val) => (
|
||||
<SelectItem key={val} value={val} className="focus:bg-zinc-900 focus:text-primary">
|
||||
<SelectItem key={val} value={val} className="focus:bg-accent focus:text-primary">
|
||||
{val}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -82,22 +82,22 @@ export function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputPro
|
||||
const getSourceBadge = () => {
|
||||
switch (field.source) {
|
||||
case 'db':
|
||||
return <Badge className="ml-2 bg-primary/20 text-primary border-primary/30 tech-glow hover:bg-primary/30 transition-colors">已配置</Badge>;
|
||||
return <Badge className="ml-2 bg-primary/20 text-primary border-primary/30 tech-glow hover:bg-accent hover:text-foreground hover:border-border/70 transition-colors">已配置</Badge>;
|
||||
case 'default':
|
||||
default:
|
||||
return <Badge variant="outline" className="ml-2 border-zinc-600 text-zinc-400">默认值</Badge>;
|
||||
return <Badge variant="outline" className="ml-2 border-border text-muted-foreground">默认值</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col py-5 px-1 gap-3 hover:bg-zinc-900/30 transition-colors rounded-lg">
|
||||
<div className="flex flex-col py-5 px-1 gap-3 hover:bg-accent/40 transition-colors rounded-lg">
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
||||
<div className="flex flex-col space-y-1.5 flex-1">
|
||||
<div className="flex items-center">
|
||||
<Label className="text-base font-semibold text-zinc-100">{field.label || field.envKey}</Label>
|
||||
<Label className="text-base font-semibold text-foreground">{field.label || field.envKey}</Label>
|
||||
{getSourceBadge()}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-400 leading-relaxed">
|
||||
<div className="text-sm text-muted-foreground leading-relaxed">
|
||||
{field.description}
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
|
||||
@@ -51,20 +51,20 @@ export function ConfigGroupCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mb-8 glass-panel border-white/10 shadow-xl overflow-hidden group">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4 space-y-0 border-b border-white/5 bg-zinc-950/30">
|
||||
<Card className="mb-8 gap-0 py-0 theme-card-shell theme-interactive-elevate group">
|
||||
<CardHeader className="theme-card-header flex flex-row items-start justify-between pb-4 space-y-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center border border-primary/20 tech-glow group-hover:bg-primary/20 transition-all duration-300">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center border border-primary/20 tech-glow group-hover:bg-accent transition-all duration-300">
|
||||
{(() => {
|
||||
const Icon = ICON_MAP[group.icon];
|
||||
return Icon ? <Icon className="h-5 w-5 text-primary" /> : <span className="text-primary">{group.icon}</span>;
|
||||
})()}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold text-zinc-100 tracking-tight">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
{group.label}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-zinc-400">
|
||||
<CardDescription className="text-muted-foreground">
|
||||
{group.description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
@@ -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"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
重置组配置
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y divide-white/5 p-6 bg-zinc-950/20">
|
||||
<CardContent className="theme-card-content divide-y divide-border/50">
|
||||
{group.fields.map((field) => {
|
||||
const custom = renderField?.(field, localConfig[field.envKey], (val) => onFieldChange(field.envKey, val));
|
||||
if (custom !== undefined) return <React.Fragment key={field.envKey}>{custom}</React.Fragment>;
|
||||
|
||||
@@ -119,34 +119,34 @@ export function ConfigManager() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<Skeleton className="h-10 w-48 bg-zinc-900/50" />
|
||||
<Skeleton className="h-10 w-24 bg-zinc-900/50" />
|
||||
<Skeleton className="h-10 w-48 bg-muted/60" />
|
||||
<Skeleton className="h-10 w-24 bg-muted/60" />
|
||||
</div>
|
||||
<Skeleton className="h-[300px] w-full rounded-xl bg-zinc-900/50 border border-white/5" />
|
||||
<Skeleton className="h-[300px] w-full rounded-xl bg-zinc-900/50 border border-white/5" />
|
||||
<Skeleton className="h-[300px] w-full rounded-xl bg-muted/60 border border-border/60" />
|
||||
<Skeleton className="h-[300px] w-full rounded-xl bg-muted/60 border border-border/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="p-4 bg-rose-500/10 border border-rose-500/20 text-rose-500 rounded-lg flex items-center gap-3 glass-panel">
|
||||
<AlertCircle className="w-5 h-5 text-rose-500" />
|
||||
<div className="theme-error-panel flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-danger" />
|
||||
<div className="font-medium tracking-wide">加载配置失败: {error.message}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-12">
|
||||
<div className="theme-page-frame">
|
||||
{/* 固定在顶部的操作栏 */}
|
||||
<div className="sticky top-0 z-10 bg-zinc-950/80 backdrop-blur-xl border-b border-white/10 py-3 px-4 md:px-6 lg:px-8 shadow-2xl">
|
||||
<div className="flex items-center justify-end gap-3 max-w-5xl mx-auto">
|
||||
<div className="sticky top-0 z-10 theme-sticky-bar py-3 px-4 md:px-6 lg:px-8">
|
||||
<div className="theme-page-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleResetAll}
|
||||
disabled={!hasOverrides || resetMutation.isPending}
|
||||
className="border-white/10 text-zinc-400 hover:text-zinc-100 hover:bg-white/5 transition-colors"
|
||||
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
全部重置
|
||||
@@ -154,11 +154,11 @@ export function ConfigManager() {
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saveMutation.isPending}
|
||||
className="min-w-[130px] bg-primary text-zinc-950 font-bold hover:bg-primary/90 tech-glow transition-all"
|
||||
className="theme-interactive-elevate min-w-[130px] bg-primary text-primary-foreground font-bold hover:bg-primary/90 tech-glow transition-all"
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="size-4 animate-spin rounded-full border-2 border-zinc-950 border-t-transparent" /> 保存中...
|
||||
<span className="size-4 animate-spin rounded-full border-2 border-primary-foreground/50 border-t-transparent" /> 保存中...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
@@ -170,7 +170,7 @@ export function ConfigManager() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-5xl mx-auto space-y-8 mt-6 px-4 md:px-6 lg:px-8">
|
||||
<div className="theme-page-content">
|
||||
{visibleGroups?.map((group) => (
|
||||
<ConfigGroupCard
|
||||
key={group.key}
|
||||
|
||||
@@ -32,14 +32,14 @@ export function DataTable<TData, TValue>({
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/50 overflow-hidden glass-panel">
|
||||
<div className="theme-card-shell overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-zinc-900 text-zinc-400 uppercase tracking-wider font-mono text-xs">
|
||||
<TableHeader className="bg-muted/45 text-muted-foreground uppercase tracking-wider font-mono text-xs border-b border-border/50">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} className="font-mono text-zinc-400">
|
||||
<TableHead key={header.id} className="font-mono text-muted-foreground">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
@@ -58,7 +58,7 @@ export function DataTable<TData, TValue>({
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="hover:bg-zinc-900/50 transition-colors border-border/50"
|
||||
className="hover:bg-accent/35 transition-colors border-border/50"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
@@ -69,9 +69,9 @@ export function DataTable<TData, TValue>({
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-48 text-center text-zinc-500 font-mono">
|
||||
<TableCell colSpan={columns.length} className="h-48 text-center text-muted-foreground font-mono">
|
||||
<div className="flex flex-col items-center justify-center space-y-3">
|
||||
<div className="p-3 rounded-full bg-zinc-900 border border-white/5 text-zinc-600">
|
||||
<div className="p-3 rounded-full bg-muted border border-border text-muted-foreground">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-database"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></svg>
|
||||
</div>
|
||||
<p>未找到匹配的仓库</p>
|
||||
|
||||
@@ -15,19 +15,19 @@ function DataTableSkeleton() {
|
||||
return (
|
||||
<div className="rounded-xl border border-border/50 overflow-hidden glass-panel">
|
||||
<Table>
|
||||
<TableHeader className="bg-zinc-900 border-b border-border/50">
|
||||
<TableHeader className="bg-muted/60 border-b border-border/50">
|
||||
<TableRow className="border-border/50">
|
||||
<TableHead className="w-[40%]"><Skeleton className="h-5 w-24 bg-zinc-800" /></TableHead>
|
||||
<TableHead className="w-[30%]"><Skeleton className="h-5 w-24 bg-zinc-800" /></TableHead>
|
||||
<TableHead className="w-[30%] text-right"><Skeleton className="h-5 w-16 ml-auto bg-zinc-800" /></TableHead>
|
||||
<TableHead className="w-[40%]"><Skeleton className="h-5 w-24 bg-muted" /></TableHead>
|
||||
<TableHead className="w-[30%]"><Skeleton className="h-5 w-24 bg-muted" /></TableHead>
|
||||
<TableHead className="w-[30%] text-right"><Skeleton className="h-5 w-16 ml-auto bg-muted" /></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<TableRow key={i} className="border-border/50">
|
||||
<TableCell><Skeleton className="h-5 w-3/4 bg-zinc-800/80" /></TableCell>
|
||||
<TableCell><Skeleton className="h-6 w-20 bg-zinc-800/80 rounded-full" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-8 w-16 ml-auto bg-zinc-800/80" /></TableCell>
|
||||
<TableCell><Skeleton className="h-5 w-3/4 bg-muted/70" /></TableCell>
|
||||
<TableCell><Skeleton className="h-6 w-20 bg-muted/70 rounded-full" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-8 w-16 ml-auto bg-muted/70" /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -60,31 +60,33 @@ export function RepositoryManager() {
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-end mb-6">
|
||||
<div className="relative w-full md:w-auto">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-zinc-500" />
|
||||
<div className="space-y-6">
|
||||
<div className="theme-card-shell p-4 md:p-5">
|
||||
<div className="flex justify-end">
|
||||
<div className="relative w-full md:w-auto">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="搜索仓库..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<DataTableSkeleton />
|
||||
) : isError ? (
|
||||
<div className="p-6 rounded-xl border border-rose-500/20 bg-rose-500/5 glass-panel">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-rose-500/10 rounded-lg text-rose-500">
|
||||
<div className="theme-error-panel p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-danger/10 rounded-lg text-danger">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-mono text-sm font-medium text-rose-500">System Error_</h3>
|
||||
<p className="font-mono text-xs text-rose-400/80">加载仓库列表失败: {error.message}</p>
|
||||
<h3 className="font-mono text-sm font-medium text-danger">System Error_</h3>
|
||||
<p className="font-mono text-xs text-danger/80">加载仓库列表失败: {error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,8 +95,8 @@ export function RepositoryManager() {
|
||||
<DataTable columns={columns} data={repos} />
|
||||
{totalPages > 1 && (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between w-full mt-6 gap-4">
|
||||
<div className="font-mono text-xs text-zinc-400 flex-shrink-0 bg-zinc-900/50 px-3 py-1.5 rounded-md border border-white/5">
|
||||
第 <span className="text-zinc-200">{page}</span> 页 / 共 <span className="text-zinc-200">{totalPages}</span> 页 <span className="text-zinc-600 mx-1">|</span> 共 <span className="text-zinc-200">{totalCount}</span> 个仓库
|
||||
<div className="theme-control-pill font-mono flex-shrink-0 rounded-md">
|
||||
第 <span className="text-foreground">{page}</span> 页 / 共 <span className="text-foreground">{totalPages}</span> 页 <span className="text-muted-foreground/70 mx-1">|</span> 共 <span className="text-foreground">{totalCount}</span> 个仓库
|
||||
</div>
|
||||
<Pagination className="flex-shrink-0 w-auto mx-0">
|
||||
<PaginationContent className="gap-2">
|
||||
@@ -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"}`}
|
||||
/>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
@@ -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"}`}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
|
||||
@@ -8,7 +8,7 @@ export const columns: ColumnDef<Repository>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "仓库名称",
|
||||
cell: ({ row }) => <div className="font-medium text-zinc-100 text-sm">{row.getValue("name")}</div>,
|
||||
cell: ({ row }) => <div className="font-medium text-foreground text-sm">{row.getValue("name")}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: "webhook_status",
|
||||
@@ -17,8 +17,8 @@ export const columns: ColumnDef<Repository>[] = [
|
||||
const status = row.getValue("webhook_status") as Repository["webhook_status"]
|
||||
const isActive = status === 'active'
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold border ${isActive ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' : 'bg-transparent text-zinc-500 border-zinc-700'}`}>
|
||||
{isActive && <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" style={{ boxShadow: '0 0 8px 1px rgba(52, 211, 153, 0.6)' }}></span>}
|
||||
<div className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold border ${isActive ? 'bg-success/10 text-success border-success/30' : 'bg-transparent text-muted-foreground border-border theme-border-soft'}`}>
|
||||
{isActive && <span className="w-1.5 h-1.5 rounded-full bg-success animate-pulse theme-glow-success"></span>}
|
||||
{isActive ? '已启用' : '未启用'}
|
||||
</div>
|
||||
)
|
||||
@@ -26,7 +26,7 @@ export const columns: ColumnDef<Repository>[] = [
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <div className="text-right text-zinc-400">操作</div>,
|
||||
header: () => <div className="text-right text-muted-foreground">操作</div>,
|
||||
cell: ({ row }) => {
|
||||
const repo = row.original
|
||||
return (
|
||||
|
||||
@@ -203,19 +203,19 @@ export function ReviewConfigPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<Skeleton className="h-10 w-48 bg-zinc-900/50" />
|
||||
<Skeleton className="h-10 w-24 bg-zinc-900/50" />
|
||||
<Skeleton className="h-10 w-48 bg-muted/60" />
|
||||
<Skeleton className="h-10 w-24 bg-muted/60" />
|
||||
</div>
|
||||
<Skeleton className="h-[200px] w-full rounded-xl bg-zinc-900/50 border border-white/5" />
|
||||
<Skeleton className="h-[300px] w-full rounded-xl bg-zinc-900/50 border border-white/5" />
|
||||
<Skeleton className="h-[200px] w-full rounded-xl bg-muted/60 border border-border/60" />
|
||||
<Skeleton className="h-[300px] w-full rounded-xl bg-muted/60 border border-border/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="p-4 bg-rose-500/10 border border-rose-500/20 text-rose-500 rounded-lg flex items-center gap-3 glass-panel">
|
||||
<AlertCircle className="w-5 h-5 text-rose-500" />
|
||||
<div className="theme-error-panel flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-danger" />
|
||||
<div className="font-medium tracking-wide">加载配置失败: {error.message}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -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'
|
||||
? <Badge className="ml-2 bg-primary/20 text-primary border-primary/30 tech-glow hover:bg-primary/30 transition-colors">已配置</Badge>
|
||||
: <Badge variant="outline" className="ml-2 border-zinc-600 text-zinc-400">默认值</Badge>;
|
||||
? <Badge className="ml-2 bg-primary/20 text-primary border-primary/30 tech-glow hover:bg-accent hover:text-foreground hover:border-border/70 transition-colors">已配置</Badge>
|
||||
: <Badge variant="outline" className="ml-2 border-border text-muted-foreground">默认值</Badge>;
|
||||
return (
|
||||
<div className="flex flex-col py-5 px-1 gap-3 hover:bg-zinc-900/30 transition-colors rounded-lg">
|
||||
<div className="flex flex-col py-5 px-1 gap-3 hover:bg-accent/40 transition-colors rounded-lg">
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
||||
<div className="flex flex-col space-y-1.5 flex-1">
|
||||
<div className="flex items-center">
|
||||
<label className="text-base font-semibold text-zinc-100">{field.label || field.envKey}</label>
|
||||
<label className="text-base font-semibold text-foreground">{field.label || field.envKey}</label>
|
||||
{sourceBadge}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-400 leading-relaxed">{field.description}</div>
|
||||
<div className="text-sm text-muted-foreground leading-relaxed">{field.description}</div>
|
||||
<div className="pt-1">
|
||||
<span className="font-mono text-xs px-2 py-0.5 rounded-md bg-primary/10 text-primary border border-primary/20 inline-flex items-center">
|
||||
{field.envKey}
|
||||
@@ -272,15 +272,15 @@ export function ReviewConfigPage() {
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-12">
|
||||
<div className="theme-page-frame">
|
||||
{/* Sticky action bar */}
|
||||
<div className="sticky top-0 z-10 bg-zinc-950/80 backdrop-blur-xl border-b border-white/10 py-3 px-4 md:px-6 lg:px-8 shadow-2xl">
|
||||
<div className="flex items-center justify-end gap-3 max-w-5xl mx-auto">
|
||||
<div className="sticky top-0 z-10 theme-sticky-bar py-3 px-4 md:px-6 lg:px-8">
|
||||
<div className="theme-page-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleResetAll}
|
||||
disabled={!hasOverrides || resetMutation.isPending}
|
||||
className="border-white/10 text-zinc-400 hover:text-zinc-100 hover:bg-white/5 transition-colors"
|
||||
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
全部重置
|
||||
@@ -288,11 +288,11 @@ export function ReviewConfigPage() {
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saveMutation.isPending}
|
||||
className="min-w-[130px] bg-primary text-zinc-950 font-bold hover:bg-primary/90 tech-glow transition-all"
|
||||
className="theme-interactive-elevate min-w-[130px] bg-primary text-primary-foreground font-bold hover:bg-primary/90 tech-glow transition-all"
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="size-4 animate-spin rounded-full border-2 border-zinc-950 border-t-transparent" /> 保存中...
|
||||
<span className="size-4 animate-spin rounded-full border-2 border-primary-foreground/50 border-t-transparent" /> 保存中...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
@@ -304,21 +304,21 @@ export function ReviewConfigPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-5xl mx-auto space-y-8 mt-6 px-4 md:px-6 lg:px-8">
|
||||
<div className="theme-page-content">
|
||||
{/* Engine Selector Card */}
|
||||
<Card className="glass-panel border-white/10 shadow-xl overflow-hidden group">
|
||||
<CardHeader className="border-b border-white/5 bg-zinc-950/30 pb-4">
|
||||
<Card className="gap-0 py-0 theme-card-shell group">
|
||||
<CardHeader className="theme-card-header pb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center border border-primary/20 tech-glow group-hover:bg-primary/20 transition-all duration-300">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center border border-primary/20 tech-glow group-hover:bg-accent transition-all duration-300">
|
||||
<Layers className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold text-zinc-100 tracking-tight">审查引擎</CardTitle>
|
||||
<CardDescription className="text-zinc-400">选择代码审查引擎模式</CardDescription>
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">审查引擎</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">选择代码审查引擎模式</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 bg-zinc-950/20">
|
||||
<CardContent className="theme-card-content">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{ENGINE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
@@ -326,19 +326,19 @@ export function ReviewConfigPage() {
|
||||
onClick={() => handleFieldChange(ENGINE_FIELD, opt.value)}
|
||||
className={`relative flex flex-col items-start gap-2 rounded-xl border p-4 text-left transition-all duration-200 ${
|
||||
engine === opt.value
|
||||
? 'border-primary/50 bg-primary/10 shadow-[0_0_20px_rgba(20,184,166,0.15)]'
|
||||
: 'border-white/10 bg-zinc-900/30 hover:bg-zinc-900/50 hover:border-white/20'
|
||||
? 'border-primary/50 bg-primary/10 theme-glow-primary'
|
||||
: 'border-border bg-muted/30 hover:bg-muted/50 hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base font-semibold text-zinc-100">{opt.label}</span>
|
||||
<span className="text-base font-semibold text-foreground">{opt.label}</span>
|
||||
{engine === opt.value && (
|
||||
<Badge className="bg-primary/20 text-primary border-primary/30 text-xs">当前</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-zinc-400">{opt.description}</span>
|
||||
<span className="text-sm text-muted-foreground">{opt.description}</span>
|
||||
{engine === opt.value && (
|
||||
<div className="absolute top-0 right-0 w-3 h-3 m-2 rounded-full bg-primary shadow-[0_0_10px_rgba(20,184,166,0.6)]" />
|
||||
<div className="absolute top-0 right-0 w-3 h-3 m-2 rounded-full bg-primary theme-glow-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -67,8 +67,8 @@ export function ModelCombobox({
|
||||
const taggedModels = buildTaggedList();
|
||||
|
||||
const TAG_STYLES: Record<string, string> = {
|
||||
'推荐': '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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOpen && !disabled && taggedModels.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 max-h-48 overflow-y-auto bg-zinc-900 border border-white/10 rounded-lg shadow-xl">
|
||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 max-h-48 overflow-y-auto bg-popover border border-border rounded-lg shadow-xl">
|
||||
<div className="py-1">
|
||||
{taggedModels.map((item, idx) => (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
key={`${item.tag}-${item.name}-${idx}`}
|
||||
className="px-3 py-2 text-sm text-zinc-200 hover:bg-white/10 cursor-pointer transition-colors flex items-center justify-between gap-2"
|
||||
className="w-full px-3 py-2 text-sm text-foreground hover:bg-accent focus-visible:bg-accent focus-visible:outline-none cursor-pointer transition-colors flex items-center justify-between gap-2"
|
||||
onClick={() => handleSelect(item.name)}
|
||||
>
|
||||
<span className="truncate">{item.name}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded flex-shrink-0 ${TAG_STYLES[item.tag]}`}>{item.tag}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,25 +91,25 @@ function ProviderDialogInner({ onOpenChange, provider }: Omit<ProviderDialogProp
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="glass-panel w-full max-w-md bg-zinc-950 border border-white/10 rounded-xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
<div className="px-6 py-4 border-b border-white/10">
|
||||
<h2 className="text-xl font-bold text-zinc-100">{isEdit ? '编辑提供商' : '添加提供商'}</h2>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center theme-surface-overlay backdrop-blur-sm">
|
||||
<div className="theme-dialog-panel">
|
||||
<div className="theme-dialog-header">
|
||||
<h2 className="text-xl font-bold text-foreground">{isEdit ? '编辑提供商' : '添加提供商'}</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 flex-1 overflow-y-auto space-y-5">
|
||||
<form onSubmit={handleSubmit} className="theme-dialog-body space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">名称 <span className="text-red-500">*</span></Label>
|
||||
<Input id="name" value={name} onChange={e => setName(e.target.value)} placeholder="如: DeepSeek" autoComplete="off" className="bg-zinc-900 border-white/10 text-white" />
|
||||
<Label htmlFor="name">名称 <span className="text-danger">*</span></Label>
|
||||
<Input id="name" value={name} onChange={e => setName(e.target.value)} placeholder="如: DeepSeek" autoComplete="off" className="bg-muted/50 border-border text-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>类型 <span className="text-red-500">*</span></Label>
|
||||
<Label>类型 <span className="text-danger">*</span></Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as ProviderType)}>
|
||||
<SelectTrigger className="bg-zinc-900 border-white/10 text-white">
|
||||
<SelectTrigger className="bg-muted/50 border-border text-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-zinc-900 border-white/10 text-white">
|
||||
<SelectContent className="bg-popover border-border text-foreground">
|
||||
{TYPE_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value} description={opt.description}>
|
||||
{opt.label}
|
||||
@@ -120,19 +120,19 @@ function ProviderDialogInner({ onOpenChange, provider }: Omit<ProviderDialogProp
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="baseUrl">Base URL {isBaseUrlRequired ? <span className="text-red-500">*</span> : <span className="text-zinc-500">(可选)</span>}</Label>
|
||||
<Label htmlFor="baseUrl">Base URL {isBaseUrlRequired ? <span className="text-danger">*</span> : <span className="text-muted-foreground">(可选)</span>}</Label>
|
||||
<Input
|
||||
id="baseUrl"
|
||||
value={baseUrl}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="defaultModel">默认模型 <span className="text-red-500">*</span></Label>
|
||||
<Label htmlFor="defaultModel">默认模型 <span className="text-danger">*</span></Label>
|
||||
<ModelCombobox
|
||||
providerType={type}
|
||||
value={defaultModel}
|
||||
@@ -142,7 +142,7 @@ function ProviderDialogInner({ onOpenChange, provider }: Omit<ProviderDialogProp
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiKey">API Key {!isEdit && <span className="text-red-500">*</span>}</Label>
|
||||
<Label htmlFor="apiKey">API Key {!isEdit && <span className="text-danger">*</span>}</Label>
|
||||
<Input
|
||||
id="apiKey"
|
||||
type="password"
|
||||
@@ -150,14 +150,14 @@ function ProviderDialogInner({ onOpenChange, provider }: Omit<ProviderDialogProp
|
||||
onChange={e => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<div className="px-6 py-4 border-t border-white/10 flex justify-end gap-3 bg-zinc-900/50">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} className="border-white/10 text-zinc-300 hover:text-white hover:bg-zinc-800">
|
||||
<div className="theme-dialog-footer flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} className="border-border text-muted-foreground hover:text-foreground hover:bg-accent">
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" onClick={handleSubmit} disabled={saveMutation.isPending} className="bg-primary text-primary-foreground hover:bg-primary/90">
|
||||
|
||||
@@ -24,10 +24,10 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
openai_compatible: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
openai_responses: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||
anthropic: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
|
||||
gemini: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
|
||||
openai_compatible: 'bg-success/10 text-success border-success/20',
|
||||
openai_responses: 'bg-info/10 text-info border-info/20',
|
||||
anthropic: 'bg-warning/10 text-warning border-warning/20',
|
||||
gemini: 'bg-primary/10 text-primary border-primary/20',
|
||||
};
|
||||
|
||||
export function ProviderList() {
|
||||
@@ -129,22 +129,22 @@ export function ProviderList() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="glass-panel border-white/10 shadow-xl overflow-hidden group">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4 space-y-0 border-b border-white/5 bg-zinc-950/30">
|
||||
<Card className="gap-0 py-0 theme-card-shell group">
|
||||
<CardHeader className="theme-card-header flex flex-row items-center justify-between pb-4 space-y-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center border border-primary/20 tech-glow group-hover:bg-primary/20 transition-all duration-300">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center border border-primary/20 tech-glow group-hover:bg-accent transition-all duration-300">
|
||||
<Activity className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold text-zinc-100 tracking-tight">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
模型提供商
|
||||
</CardTitle>
|
||||
<CardDescription className="text-zinc-400">
|
||||
<CardDescription className="text-muted-foreground">
|
||||
管理连接的 LLM API 服务及其访问密钥
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={openAdd} className="bg-primary text-primary-foreground hover:bg-primary/90 shadow-[0_0_15px_rgba(20,184,166,0.3)] transition-all">
|
||||
<Button onClick={openAdd} className="bg-primary text-primary-foreground hover:bg-primary/90 theme-glow-primary transition-all">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加提供商
|
||||
</Button>
|
||||
@@ -152,20 +152,20 @@ export function ProviderList() {
|
||||
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader className="bg-zinc-900/50">
|
||||
<TableRow className="border-white/5 hover:bg-transparent">
|
||||
<TableHead className="text-zinc-400 font-medium h-12">名称</TableHead>
|
||||
<TableHead className="text-zinc-400 font-medium h-12">类型</TableHead>
|
||||
<TableHead className="text-zinc-400 font-medium h-12">默认模型</TableHead>
|
||||
<TableHead className="text-zinc-400 font-medium h-12 text-center">状态</TableHead>
|
||||
<TableHead className="text-zinc-400 font-medium h-12 text-center">启用</TableHead>
|
||||
<TableHead className="text-zinc-400 font-medium h-12 text-right">操作</TableHead>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow className="border-border/60 hover:bg-transparent">
|
||||
<TableHead className="text-muted-foreground font-medium h-12">名称</TableHead>
|
||||
<TableHead className="text-muted-foreground font-medium h-12">类型</TableHead>
|
||||
<TableHead className="text-muted-foreground font-medium h-12">默认模型</TableHead>
|
||||
<TableHead className="text-muted-foreground font-medium h-12 text-center">状态</TableHead>
|
||||
<TableHead className="text-muted-foreground font-medium h-12 text-center">启用</TableHead>
|
||||
<TableHead className="text-muted-foreground font-medium h-12 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow className="border-white/5 hover:bg-zinc-900/30">
|
||||
<TableCell colSpan={6} className="h-24 text-center text-zinc-500">
|
||||
<TableRow className="border-border/60 hover:bg-muted/30">
|
||||
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
加载中...
|
||||
@@ -173,31 +173,31 @@ export function ProviderList() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : providers.length === 0 ? (
|
||||
<TableRow className="border-white/5 hover:bg-zinc-900/30">
|
||||
<TableCell colSpan={6} className="h-24 text-center text-zinc-500">
|
||||
<TableRow className="border-border/60 hover:bg-muted/30">
|
||||
<TableCell colSpan={6} className="h-24 text-center text-muted-foreground">
|
||||
暂无提供商配置,请点击右上角添加。
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
providers.map(provider => (
|
||||
<TableRow key={provider.id} className="border-white/5 hover:bg-zinc-900/30 transition-colors">
|
||||
<TableCell className="font-medium text-zinc-200">
|
||||
<TableRow key={provider.id} className="border-border/60 hover:bg-muted/30 transition-colors">
|
||||
<TableCell className="font-medium text-foreground">
|
||||
{provider.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={`font-normal ${TYPE_COLORS[provider.type] || 'text-zinc-400'}`}>
|
||||
<Badge variant="outline" className={`font-normal ${TYPE_COLORS[provider.type] || 'text-muted-foreground'}`}>
|
||||
{TYPE_LABELS[provider.type] || provider.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-zinc-300">
|
||||
<code className="bg-white/5 px-1.5 py-0.5 rounded text-xs text-primary/80">
|
||||
<TableCell className="text-foreground/90">
|
||||
<code className="bg-muted/60 px-1.5 py-0.5 rounded text-xs text-primary/80">
|
||||
{provider.defaultModel}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-1.5" title={provider.hasKey ? '已配置 API Key' : '未配置 API Key'}>
|
||||
<span className={`w-2 h-2 rounded-full ${provider.hasKey ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]' : 'bg-zinc-600'}`} />
|
||||
<span className="text-xs text-zinc-400">{provider.hasKey ? '就绪' : '无 Key'}</span>
|
||||
<span className={`w-2 h-2 rounded-full ${provider.hasKey ? 'bg-success theme-glow-success' : 'bg-muted-foreground/60'}`} />
|
||||
<span className="text-xs text-muted-foreground">{provider.hasKey ? '就绪' : '无 Key'}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
@@ -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="编辑"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
@@ -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="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
|
||||
@@ -112,65 +112,65 @@ export function RoleAssignment() {
|
||||
const enabledProviders = providers.filter(p => p.isEnabled && p.hasKey);
|
||||
|
||||
return (
|
||||
<Card className="glass-panel border-white/10 shadow-xl overflow-hidden group">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4 space-y-0 border-b border-white/5 bg-zinc-950/30">
|
||||
<Card className="gap-0 py-0 theme-card-shell group">
|
||||
<CardHeader className="theme-card-header flex flex-row items-center justify-between pb-4 space-y-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center border border-amber-500/20 group-hover:bg-amber-500/20 transition-all duration-300">
|
||||
<ShieldCheck className="h-5 w-5 text-amber-400" />
|
||||
<div className="w-10 h-10 rounded-xl bg-warning/10 flex items-center justify-center border border-warning/20 group-hover:bg-warning/20 transition-all duration-300">
|
||||
<ShieldCheck className="h-5 w-5 text-warning" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold text-zinc-100 tracking-tight">
|
||||
角色分配
|
||||
</CardTitle>
|
||||
<CardDescription className="text-zinc-400">
|
||||
为 AI 审查系统的不同角色指定提供商和模型
|
||||
</CardDescription>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
角色分配
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
为 AI 审查系统的不同角色指定提供商和模型
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6 bg-zinc-950/20">
|
||||
<CardContent className="theme-card-content">
|
||||
{isLoading ? (
|
||||
<div className="h-32 flex items-center justify-center text-zinc-500 gap-2">
|
||||
<div className="h-32 flex items-center justify-center text-muted-foreground gap-2">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
加载角色配置...
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-white/5">
|
||||
<div className="divide-y divide-border/50">
|
||||
{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 (
|
||||
<div key={role} className="flex flex-col md:flex-row items-start md:items-center gap-4 py-5 px-1 hover:bg-zinc-900/30 transition-colors rounded-lg">
|
||||
<div key={role} className="flex flex-col md:flex-row items-start md:items-center gap-4 py-5 px-1 hover:bg-accent/40 transition-colors rounded-lg">
|
||||
<div className="w-full md:w-1/3 space-y-1.5">
|
||||
<Label className="text-base font-semibold text-zinc-100">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{ROLE_LABELS[role]?.label || role}
|
||||
</Label>
|
||||
<p className="text-sm text-zinc-400 leading-relaxed">
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{ROLE_LABELS[role]?.desc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-2/3 flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
||||
<div className="flex-1 w-full space-y-1">
|
||||
<Label className="text-xs text-zinc-400">提供商</Label>
|
||||
<Label className="text-xs text-muted-foreground">提供商</Label>
|
||||
<Select
|
||||
value={state.providerId || ''}
|
||||
onValueChange={(v) => handleProviderChange(role, v)}
|
||||
>
|
||||
<SelectTrigger className="bg-zinc-900/50 border-white/10 text-white">
|
||||
<SelectTrigger className="bg-muted/50 border-border text-foreground">
|
||||
<SelectValue placeholder="选择提供商" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-zinc-950 border-white/10 text-white">
|
||||
<SelectContent className="bg-popover border-border text-foreground">
|
||||
{enabledProviders.map(p => (
|
||||
<SelectItem key={p.id} value={p.id} description={p.type} className="focus:bg-zinc-900 focus:text-primary">
|
||||
<SelectItem key={p.id} value={p.id} description={p.type} className="focus:bg-accent focus:text-primary">
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{enabledProviders.length === 0 && (
|
||||
<div className="px-2 py-3 text-xs text-red-400 text-center border-t border-white/5">
|
||||
<div className="px-2 py-3 text-xs text-danger text-center border-t border-border/60">
|
||||
无可用提供商。请先添加并启用。
|
||||
</div>
|
||||
)}
|
||||
@@ -179,7 +179,7 @@ export function RoleAssignment() {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 w-full space-y-1">
|
||||
<Label className="text-xs text-zinc-400">使用的模型</Label>
|
||||
<Label className="text-xs text-muted-foreground">使用的模型</Label>
|
||||
<ModelCombobox
|
||||
providerType={providers.find(p => p.id === state.providerId)?.type}
|
||||
value={state.model}
|
||||
@@ -196,7 +196,7 @@ export function RoleAssignment() {
|
||||
onClick={() => handleSave(role)}
|
||||
disabled={!isDirty || saveMutation.isPending}
|
||||
variant={isDirty ? 'default' : 'secondary'}
|
||||
className={`transition-all ${isDirty ? 'bg-amber-500/20 text-amber-400 border border-amber-500/30 hover:bg-amber-500/30' : 'bg-white/5 text-zinc-500 border border-transparent'}`}
|
||||
className={`transition-all ${isDirty ? 'bg-warning/15 text-warning border border-warning/30 hover:bg-warning/25' : 'bg-muted/50 text-muted-foreground border border-transparent'}`}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1.5" />
|
||||
{isDirty ? '保存更改' : '已保存'}
|
||||
|
||||
@@ -13,39 +13,39 @@ export function TestResultDialog({ open, onOpenChange, result, providerName }: T
|
||||
if (!open || !result) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="glass-panel w-full max-w-md bg-zinc-950 border border-white/10 rounded-xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
<div className="px-6 py-4 border-b border-white/10">
|
||||
<h2 className="text-xl font-bold text-zinc-100">测试结果 - {providerName}</h2>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center theme-surface-overlay backdrop-blur-sm">
|
||||
<div className="theme-dialog-panel">
|
||||
<div className="theme-dialog-header">
|
||||
<h2 className="text-xl font-bold text-foreground">测试结果 - {providerName}</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-6 flex-1 overflow-y-auto space-y-5">
|
||||
<div className="theme-dialog-body space-y-5">
|
||||
{result.success ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-green-500">
|
||||
<div className="flex items-center gap-3 text-success">
|
||||
<CheckCircle2 className="w-8 h-8" />
|
||||
<span className="text-lg font-medium">连接成功</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm text-zinc-300">
|
||||
<div className="space-y-2 text-sm text-foreground/90">
|
||||
{result.latencyMs !== undefined && (
|
||||
<div className="flex justify-between border-b border-white/5 pb-2">
|
||||
<span className="text-zinc-500">延迟:</span>
|
||||
<div className="flex justify-between border-b border-border/60 pb-2">
|
||||
<span className="text-muted-foreground">延迟:</span>
|
||||
<span>{result.latencyMs} ms</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.model && (
|
||||
<div className="flex justify-between border-b border-white/5 pb-2">
|
||||
<span className="text-zinc-500">模型:</span>
|
||||
<div className="flex justify-between border-b border-border/60 pb-2">
|
||||
<span className="text-muted-foreground">模型:</span>
|
||||
<span className="font-mono">{result.model}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.message && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<span className="text-zinc-500">AI 响应:</span>
|
||||
<div className="bg-zinc-900 border border-white/10 rounded-md p-3 max-h-48 overflow-y-auto whitespace-pre-wrap font-mono text-xs">
|
||||
<span className="text-muted-foreground">AI 响应:</span>
|
||||
<div className="bg-muted/60 border border-border rounded-md p-3 max-h-48 overflow-y-auto whitespace-pre-wrap font-mono text-xs">
|
||||
{result.message}
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,22 +54,22 @@ export function TestResultDialog({ open, onOpenChange, result, providerName }: T
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-red-500">
|
||||
<div className="flex items-center gap-3 text-danger">
|
||||
<XCircle className="w-8 h-8" />
|
||||
<span className="text-lg font-medium">测试失败</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm text-zinc-300">
|
||||
<div className="space-y-2 text-sm text-foreground/90">
|
||||
{result.latencyMs !== undefined && (
|
||||
<div className="flex justify-between border-b border-white/5 pb-2">
|
||||
<span className="text-zinc-500">延迟:</span>
|
||||
<div className="flex justify-between border-b border-border/60 pb-2">
|
||||
<span className="text-muted-foreground">延迟:</span>
|
||||
<span>{result.latencyMs} ms</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<span className="text-zinc-500">错误:</span>
|
||||
<div className="bg-zinc-900/50 border border-red-500/20 text-red-400 rounded-md p-3 max-h-48 overflow-y-auto whitespace-pre-wrap font-mono text-xs">
|
||||
<span className="text-muted-foreground">错误:</span>
|
||||
<div className="bg-danger/10 border border-danger/20 text-danger rounded-md p-3 max-h-48 overflow-y-auto whitespace-pre-wrap font-mono text-xs">
|
||||
{result.error || result.message || '未知错误'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,7 +78,7 @@ export function TestResultDialog({ open, onOpenChange, result, providerName }: T
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-white/10 flex justify-end bg-zinc-900/50">
|
||||
<div className="theme-dialog-footer flex justify-end">
|
||||
<Button type="button" onClick={() => onOpenChange(false)} className="bg-primary text-primary-foreground hover:bg-primary/90">
|
||||
关闭
|
||||
</Button>
|
||||
|
||||
@@ -19,8 +19,9 @@ function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.V
|
||||
function SelectTrigger({
|
||||
className,
|
||||
children,
|
||||
hideIndicator = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { hideIndicator?: boolean }) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
@@ -31,9 +32,11 @@ function SelectTrigger({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
{!hideIndicator && (
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
)}
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
58
frontend/src/hooks/useColorPalette.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
|
||||
export const COLOR_PALETTE_STORAGE_KEY = 'ui-color-palette';
|
||||
|
||||
export const COLOR_PALETTES = ['cobalt', 'zinc', 'nord', 'tokyo-night'] as const;
|
||||
|
||||
export type ColorPalette = (typeof COLOR_PALETTES)[number];
|
||||
|
||||
type ColorPaletteContextValue = {
|
||||
palette: ColorPalette;
|
||||
setPalette: (palette: ColorPalette) => void;
|
||||
};
|
||||
|
||||
const ColorPaletteContext = createContext<ColorPaletteContextValue | null>(null);
|
||||
|
||||
export const isColorPalette = (value: string): value is ColorPalette =>
|
||||
COLOR_PALETTES.includes(value as ColorPalette);
|
||||
|
||||
const resolveInitialPalette = (): ColorPalette => {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'cobalt';
|
||||
}
|
||||
|
||||
const stored = window.localStorage.getItem(COLOR_PALETTE_STORAGE_KEY);
|
||||
if (stored && isColorPalette(stored)) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
return 'cobalt';
|
||||
};
|
||||
|
||||
export const ColorPaletteProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [palette, setPalette] = useState<ColorPalette>(resolveInitialPalette);
|
||||
|
||||
useEffect(() => {
|
||||
window.document.documentElement.setAttribute('data-palette', palette);
|
||||
window.localStorage.setItem(COLOR_PALETTE_STORAGE_KEY, palette);
|
||||
}, [palette]);
|
||||
|
||||
const value = useMemo<ColorPaletteContextValue>(
|
||||
() => ({
|
||||
palette,
|
||||
setPalette,
|
||||
}),
|
||||
[palette]
|
||||
);
|
||||
|
||||
return <ColorPaletteContext.Provider value={value}>{children}</ColorPaletteContext.Provider>;
|
||||
};
|
||||
|
||||
export const useColorPalette = () => {
|
||||
const context = useContext(ColorPaletteContext);
|
||||
if (!context) {
|
||||
throw new Error('useColorPalette must be used within ColorPaletteProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
@@ -6,48 +6,313 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--background: 240 10% 98%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 180 100% 35%;
|
||||
--primary: 224 76% 52%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 180 100% 35%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--accent: 220 18% 94%;
|
||||
--accent-foreground: 240 10% 8%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 180 100% 35%;
|
||||
--ring: 224 76% 52%;
|
||||
|
||||
--success: 160 84% 39%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
--warning: 38 92% 50%;
|
||||
--warning-foreground: 24 10% 10%;
|
||||
--danger: 0 72% 51%;
|
||||
--danger-foreground: 0 0% 100%;
|
||||
--info: 214 89% 55%;
|
||||
--info-foreground: 0 0% 100%;
|
||||
|
||||
--surface-muted: 240 8% 94%;
|
||||
--surface-elevated: 0 0% 100%;
|
||||
--surface-overlay: 240 16% 14%;
|
||||
|
||||
--text-subtle: 240 4% 43%;
|
||||
--text-soft: 240 4% 58%;
|
||||
|
||||
--border-soft: 240 6% 84%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--background: 240 10% 4%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 6%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 6%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 175 90% 45%;
|
||||
--primary-foreground: 240 10% 4%;
|
||||
--primary: 224 88% 68%;
|
||||
--primary-foreground: 224 40% 12%;
|
||||
--secondary: 240 5% 15%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 5% 15%;
|
||||
--muted-foreground: 240 5% 65%;
|
||||
--accent: 175 90% 45%;
|
||||
--accent-foreground: 240 10% 4%;
|
||||
--accent: 240 6% 16%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5% 15%;
|
||||
--input: 240 5% 15%;
|
||||
--ring: 175 90% 45%;
|
||||
--ring: 224 88% 68%;
|
||||
|
||||
--success: 160 80% 46%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
--warning: 39 92% 58%;
|
||||
--warning-foreground: 24 10% 10%;
|
||||
--danger: 0 80% 63%;
|
||||
--danger-foreground: 0 0% 100%;
|
||||
--info: 214 88% 65%;
|
||||
--info-foreground: 0 0% 100%;
|
||||
|
||||
--surface-muted: 240 6% 11%;
|
||||
--surface-elevated: 240 8% 14%;
|
||||
--surface-overlay: 240 5% 6%;
|
||||
|
||||
--text-subtle: 240 5% 72%;
|
||||
--text-soft: 240 5% 60%;
|
||||
--border-soft: 240 5% 21%;
|
||||
}
|
||||
|
||||
:root[data-palette='zinc'] {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
|
||||
--success: 142.1 76.2% 36.3%;
|
||||
--success-foreground: 355.7 100% 97.3%;
|
||||
--warning: 47.9 95.8% 53.1%;
|
||||
--warning-foreground: 26 83.3% 14.1%;
|
||||
--danger: 0 84.2% 60.2%;
|
||||
--danger-foreground: 0 0% 98%;
|
||||
--info: 221.2 83.2% 53.3%;
|
||||
--info-foreground: 210 40% 98%;
|
||||
|
||||
--surface-muted: 240 4.8% 95.9%;
|
||||
--surface-elevated: 0 0% 100%;
|
||||
--surface-overlay: 240 10% 3.9%;
|
||||
--text-subtle: 240 3.8% 46.1%;
|
||||
--text-soft: 240 5% 64.9%;
|
||||
--border-soft: 240 5.9% 84%;
|
||||
}
|
||||
|
||||
.dark[data-palette='zinc'] {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
|
||||
--success: 142.1 70.6% 45.3%;
|
||||
--success-foreground: 144.9 80.4% 10%;
|
||||
--warning: 47.9 95.8% 53.1%;
|
||||
--warning-foreground: 26 83.3% 14.1%;
|
||||
--danger: 0 72% 51%;
|
||||
--danger-foreground: 0 0% 98%;
|
||||
--info: 217.2 91.2% 59.8%;
|
||||
--info-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--surface-muted: 240 3.7% 15.9%;
|
||||
--surface-elevated: 240 3.7% 18%;
|
||||
--surface-overlay: 240 23% 9%;
|
||||
--text-subtle: 240 5% 64.9%;
|
||||
--text-soft: 240 5% 56%;
|
||||
--border-soft: 240 3.7% 24%;
|
||||
}
|
||||
|
||||
:root[data-palette='nord'] {
|
||||
--background: 220 13% 91%;
|
||||
--foreground: 220 13% 17%;
|
||||
--card: 220 13% 88%;
|
||||
--card-foreground: 220 13% 17%;
|
||||
--popover: 220 13% 91%;
|
||||
--popover-foreground: 220 13% 17%;
|
||||
--primary: 204 48% 68%;
|
||||
--primary-foreground: 220 13% 17%;
|
||||
--secondary: 213 31% 48%;
|
||||
--secondary-foreground: 220 13% 91%;
|
||||
--muted: 220 13% 83%;
|
||||
--muted-foreground: 220 13% 35%;
|
||||
--accent: 192 34% 64%;
|
||||
--accent-foreground: 220 13% 17%;
|
||||
--destructive: 353 50% 57%;
|
||||
--destructive-foreground: 220 13% 91%;
|
||||
--border: 220 13% 75%;
|
||||
--input: 220 13% 75%;
|
||||
--ring: 204 48% 68%;
|
||||
|
||||
--success: 136 44% 64%;
|
||||
--success-foreground: 220 13% 17%;
|
||||
--warning: 43 74% 73%;
|
||||
--warning-foreground: 220 13% 17%;
|
||||
--danger: 353 50% 57%;
|
||||
--danger-foreground: 220 13% 91%;
|
||||
--info: 222 63% 70%;
|
||||
--info-foreground: 220 13% 17%;
|
||||
|
||||
--surface-muted: 220 13% 83%;
|
||||
--surface-elevated: 220 13% 88%;
|
||||
--surface-overlay: 220 13% 17%;
|
||||
--text-subtle: 220 13% 35%;
|
||||
--text-soft: 220 13% 45%;
|
||||
--border-soft: 220 13% 80%;
|
||||
}
|
||||
|
||||
.dark[data-palette='nord'] {
|
||||
--background: 220 13% 12%;
|
||||
--foreground: 220 13% 91%;
|
||||
--card: 220 13% 16%;
|
||||
--card-foreground: 220 13% 91%;
|
||||
--popover: 220 13% 16%;
|
||||
--popover-foreground: 220 13% 91%;
|
||||
--primary: 204 48% 68%;
|
||||
--primary-foreground: 220 13% 12%;
|
||||
--secondary: 213 31% 48%;
|
||||
--secondary-foreground: 220 13% 91%;
|
||||
--muted: 220 13% 24%;
|
||||
--muted-foreground: 220 13% 70%;
|
||||
--accent: 192 34% 64%;
|
||||
--accent-foreground: 220 13% 91%;
|
||||
--destructive: 353 50% 57%;
|
||||
--destructive-foreground: 220 13% 91%;
|
||||
--border: 220 13% 28%;
|
||||
--input: 220 13% 28%;
|
||||
--ring: 204 48% 68%;
|
||||
|
||||
--success: 136 44% 64%;
|
||||
--success-foreground: 220 13% 12%;
|
||||
--warning: 43 74% 73%;
|
||||
--warning-foreground: 220 13% 12%;
|
||||
--danger: 353 50% 57%;
|
||||
--danger-foreground: 220 13% 91%;
|
||||
--info: 222 63% 70%;
|
||||
--info-foreground: 220 13% 12%;
|
||||
|
||||
--surface-muted: 220 13% 24%;
|
||||
--surface-elevated: 220 13% 16%;
|
||||
--surface-overlay: 220 13% 12%;
|
||||
--text-subtle: 220 13% 70%;
|
||||
--text-soft: 220 13% 60%;
|
||||
--border-soft: 220 13% 20%;
|
||||
}
|
||||
|
||||
:root[data-palette='tokyo-night'] {
|
||||
--background: 230 15% 95%;
|
||||
--foreground: 230 20% 15%;
|
||||
--card: 230 15% 92%;
|
||||
--card-foreground: 230 20% 15%;
|
||||
--popover: 230 15% 92%;
|
||||
--popover-foreground: 230 20% 15%;
|
||||
--primary: 219 89% 72%;
|
||||
--primary-foreground: 230 20% 15%;
|
||||
--secondary: 268 89% 78%;
|
||||
--secondary-foreground: 230 20% 15%;
|
||||
--muted: 230 10% 85%;
|
||||
--muted-foreground: 230 10% 45%;
|
||||
--accent: 195 100% 74%;
|
||||
--accent-foreground: 230 20% 15%;
|
||||
--destructive: 343 91% 73%;
|
||||
--destructive-foreground: 230 15% 98%;
|
||||
--border: 230 10% 80%;
|
||||
--input: 230 10% 80%;
|
||||
--ring: 219 89% 72%;
|
||||
|
||||
--success: 80 78% 62%;
|
||||
--success-foreground: 230 20% 15%;
|
||||
--warning: 35 85% 66%;
|
||||
--warning-foreground: 230 20% 15%;
|
||||
--danger: 343 91% 73%;
|
||||
--danger-foreground: 230 15% 98%;
|
||||
--info: 195 100% 74%;
|
||||
--info-foreground: 230 20% 15%;
|
||||
|
||||
--surface-muted: 230 10% 85%;
|
||||
--surface-elevated: 230 15% 92%;
|
||||
--surface-overlay: 230 20% 15%;
|
||||
--text-subtle: 230 10% 45%;
|
||||
--text-soft: 230 10% 56%;
|
||||
--border-soft: 230 10% 85%;
|
||||
}
|
||||
|
||||
.dark[data-palette='tokyo-night'] {
|
||||
--background: 232 23% 10%;
|
||||
--foreground: 219 28% 88%;
|
||||
--card: 232 20% 14%;
|
||||
--card-foreground: 219 28% 88%;
|
||||
--popover: 232 20% 14%;
|
||||
--popover-foreground: 219 28% 88%;
|
||||
--primary: 219 89% 72%;
|
||||
--primary-foreground: 232 23% 10%;
|
||||
--secondary: 268 89% 78%;
|
||||
--secondary-foreground: 232 23% 10%;
|
||||
--muted: 232 20% 20%;
|
||||
--muted-foreground: 219 28% 60%;
|
||||
--accent: 195 100% 74%;
|
||||
--accent-foreground: 232 23% 10%;
|
||||
--destructive: 343 91% 73%;
|
||||
--destructive-foreground: 219 28% 88%;
|
||||
--border: 232 20% 25%;
|
||||
--input: 232 20% 25%;
|
||||
--ring: 219 89% 72%;
|
||||
|
||||
--success: 80 78% 62%;
|
||||
--success-foreground: 232 23% 10%;
|
||||
--warning: 35 85% 66%;
|
||||
--warning-foreground: 232 23% 10%;
|
||||
--danger: 343 91% 73%;
|
||||
--danger-foreground: 219 28% 88%;
|
||||
--info: 195 100% 74%;
|
||||
--info-foreground: 232 23% 10%;
|
||||
|
||||
--surface-muted: 232 20% 20%;
|
||||
--surface-elevated: 232 20% 14%;
|
||||
--surface-overlay: 232 23% 10%;
|
||||
--text-subtle: 219 28% 60%;
|
||||
--text-soft: 219 28% 52%;
|
||||
--border-soft: 232 20% 18%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,10 +343,149 @@
|
||||
.glass-panel {
|
||||
@apply bg-card/80 backdrop-blur-xl border border-border shadow-2xl;
|
||||
}
|
||||
|
||||
.theme-surface-muted {
|
||||
background: hsl(var(--surface-muted));
|
||||
}
|
||||
|
||||
.theme-surface-elevated {
|
||||
background: hsl(var(--surface-elevated));
|
||||
}
|
||||
|
||||
.theme-surface-overlay {
|
||||
background: hsl(var(--surface-overlay) / 0.62);
|
||||
}
|
||||
|
||||
.theme-border-soft {
|
||||
border-color: hsl(var(--border-soft));
|
||||
}
|
||||
|
||||
.theme-text-subtle {
|
||||
color: hsl(var(--text-subtle));
|
||||
}
|
||||
|
||||
.theme-text-soft {
|
||||
color: hsl(var(--text-soft));
|
||||
}
|
||||
|
||||
.theme-glow-primary {
|
||||
box-shadow: 0 0 18px -6px hsl(var(--primary) / 0.45);
|
||||
}
|
||||
|
||||
.theme-glow-success {
|
||||
box-shadow: 0 0 10px 0 hsl(var(--success) / 0.55);
|
||||
}
|
||||
|
||||
.theme-glow-warning {
|
||||
box-shadow: 0 0 10px 0 hsl(var(--warning) / 0.55);
|
||||
}
|
||||
|
||||
.theme-glow-danger {
|
||||
box-shadow: 0 0 10px 0 hsl(var(--danger) / 0.55);
|
||||
}
|
||||
|
||||
.theme-shell-gradient {
|
||||
background-image:
|
||||
radial-gradient(circle at 12% 8%, hsl(var(--primary) / 0.1), transparent 34%),
|
||||
radial-gradient(circle at 92% 82%, hsl(var(--accent) / 0.12), transparent 38%),
|
||||
linear-gradient(180deg, hsl(var(--background) / 0.98), hsl(var(--background)) 40%);
|
||||
}
|
||||
|
||||
.theme-control-pill {
|
||||
@apply inline-flex items-center gap-2 rounded-full border border-border/70 bg-muted/55 px-3 py-1.5 text-xs font-medium text-muted-foreground backdrop-blur;
|
||||
}
|
||||
|
||||
.theme-input-surface {
|
||||
@apply bg-muted/45 border-border/70 text-foreground placeholder:text-muted-foreground/70 focus-visible:border-primary/40 focus-visible:ring-primary/20;
|
||||
}
|
||||
|
||||
.theme-interactive-elevate {
|
||||
transition: transform 180ms ease, box-shadow 220ms ease, border-color 220ms ease;
|
||||
}
|
||||
|
||||
.theme-interactive-elevate:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 16px 35px -30px hsl(var(--foreground) / 0.8);
|
||||
border-color: hsl(var(--border-soft));
|
||||
}
|
||||
|
||||
.theme-sticky-bar {
|
||||
@apply backdrop-blur-2xl border-b border-border/70;
|
||||
background: hsl(var(--background) / 0.88);
|
||||
box-shadow: 0 12px 30px -26px hsl(var(--foreground) / 0.55);
|
||||
}
|
||||
|
||||
.theme-sidebar-shell {
|
||||
@apply border-r border-border/70 backdrop-blur-xl;
|
||||
background:
|
||||
linear-gradient(180deg, hsl(var(--surface-elevated) / 0.9) 0%, hsl(var(--background) / 0.86) 100%);
|
||||
box-shadow: inset -1px 0 0 hsl(var(--border-soft) / 0.35);
|
||||
}
|
||||
|
||||
.theme-sidebar-header {
|
||||
@apply border-b border-border/60;
|
||||
background: hsl(var(--surface-elevated) / 0.9);
|
||||
}
|
||||
|
||||
.theme-sidebar-footer {
|
||||
@apply border-t border-border/55;
|
||||
background: hsl(var(--background) / 0.78);
|
||||
}
|
||||
|
||||
.theme-page-frame {
|
||||
@apply min-h-screen pb-12;
|
||||
}
|
||||
|
||||
.theme-page-actions {
|
||||
@apply flex flex-wrap items-center justify-end gap-3 max-w-6xl mx-auto;
|
||||
}
|
||||
|
||||
.theme-page-content {
|
||||
@apply max-w-6xl mx-auto mt-7 space-y-8 px-4 md:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.theme-card-shell {
|
||||
@apply relative overflow-hidden rounded-2xl border border-border/70 backdrop-blur-xl;
|
||||
background: hsl(var(--card) / 0.88);
|
||||
box-shadow: 0 20px 50px -42px hsl(var(--foreground) / 0.85);
|
||||
}
|
||||
|
||||
.theme-card-header {
|
||||
@apply border-b border-border/60 bg-muted/35 px-6 py-5;
|
||||
}
|
||||
|
||||
.theme-card-content {
|
||||
@apply p-6 bg-background/35;
|
||||
}
|
||||
|
||||
.theme-error-panel {
|
||||
@apply glass-panel p-4 rounded-lg text-danger border border-danger/20 bg-danger/10;
|
||||
}
|
||||
|
||||
.theme-dialog-panel {
|
||||
@apply glass-panel w-full max-w-md bg-card border border-border rounded-xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh];
|
||||
}
|
||||
|
||||
.theme-dialog-header {
|
||||
@apply px-6 py-4 border-b border-border;
|
||||
}
|
||||
|
||||
.theme-dialog-body {
|
||||
@apply px-6 py-5 overflow-y-auto flex-1;
|
||||
}
|
||||
|
||||
.theme-dialog-footer {
|
||||
@apply px-6 py-4 border-t border-border bg-muted/40;
|
||||
}
|
||||
|
||||
.theme-spin-reverse-slow {
|
||||
animation: spin 1.5s linear infinite reverse;
|
||||
}
|
||||
|
||||
.tech-glow {
|
||||
box-shadow: 0 0 20px -5px hsl(var(--primary) / 0.5);
|
||||
box-shadow: 0 0 18px -8px hsl(var(--foreground) / 0.2);
|
||||
}
|
||||
.tech-glow:hover {
|
||||
box-shadow: 0 0 30px -5px hsl(var(--primary) / 0.7);
|
||||
box-shadow: 0 0 24px -8px hsl(var(--foreground) / 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LogOut, Bot, FolderGit2, Sliders, Menu, X, PanelLeftClose, PanelLeftOpen, FileSearch, Sun, Moon } from 'lucide-react';
|
||||
import { LogOut, Bot, FolderGit2, Sliders, Menu, X, PanelLeftClose, PanelLeftOpen, FileSearch, Sun, Moon, Palette } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
|
||||
import { isColorPalette, useColorPalette } from '@/hooks/useColorPalette';
|
||||
|
||||
const navItems = [
|
||||
{ path: '/repos', label: '仓库管理', icon: FolderGit2 },
|
||||
@@ -13,6 +15,7 @@ const navItems = [
|
||||
export default function DashboardPage() {
|
||||
const location = useLocation();
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
const { palette, setPalette } = useColorPalette();
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
@@ -31,24 +34,24 @@ export default function DashboardPage() {
|
||||
const isReviewConfigPage = location.pathname.startsWith('/review-config');
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-background">
|
||||
<div className="theme-shell-gradient flex h-screen w-full overflow-hidden bg-background">
|
||||
{/* Mobile Overlay */}
|
||||
{isMobileMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-foreground/60 backdrop-blur-sm lg:hidden animate-in fade-in"
|
||||
className="fixed inset-0 z-40 theme-surface-overlay backdrop-blur-md lg:hidden animate-in fade-in"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 flex flex-col border-r border-border bg-card transition-all duration-300 ease-in-out lg:relative ${
|
||||
isSidebarCollapsed ? 'w-[72px]' : 'w-64'
|
||||
} ${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}`}
|
||||
className={`theme-sidebar-shell fixed inset-y-0 left-0 z-50 flex flex-col transition-all duration-300 ease-in-out ${
|
||||
isSidebarCollapsed ? 'w-[72px]' : 'w-64'
|
||||
} ${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}`}
|
||||
>
|
||||
<div className="flex h-16 items-center justify-between px-4 border-b border-border/50 bg-card">
|
||||
<div className="theme-sidebar-header flex h-16 items-center justify-between px-4">
|
||||
<div className={`flex items-center gap-3 overflow-hidden transition-all duration-300 ${isSidebarCollapsed ? 'w-10 justify-center -ml-1' : 'w-full'}`}>
|
||||
<div className="flex shrink-0 h-9 w-9 items-center justify-center rounded-xl bg-primary/10 text-primary border border-primary/20 shadow-[0_0_15px_rgba(20,184,166,0.15)] ring-1 ring-primary/10">
|
||||
<div className="theme-interactive-elevate flex shrink-0 h-9 w-9 items-center justify-center rounded-xl bg-primary/10 text-primary border border-primary/20 theme-glow-primary ring-1 ring-primary/10">
|
||||
<Bot className="h-5 w-5" />
|
||||
</div>
|
||||
{!isSidebarCollapsed && (
|
||||
@@ -75,18 +78,18 @@ export default function DashboardPage() {
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`group relative flex w-full items-center rounded-xl p-2.5 transition-all duration-200 ${
|
||||
`group relative flex w-full items-center rounded-xl border p-2.5 transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
|
||||
} ${isSidebarCollapsed ? 'justify-center' : 'justify-start gap-3'}`
|
||||
}
|
||||
? 'border-primary/[0.14] bg-primary/[0.08] text-primary shadow-sm'
|
||||
: 'border-transparent text-muted-foreground hover:bg-accent/50 hover:border-border/60 hover:text-foreground'
|
||||
} ${isSidebarCollapsed ? 'justify-center' : 'justify-start gap-3'}`
|
||||
}
|
||||
title={isSidebarCollapsed ? item.label : undefined}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 h-1/2 w-1 -translate-y-1/2 rounded-r-full bg-primary shadow-[0_0_10px_rgba(20,184,166,0.5)]"></div>
|
||||
<div className="absolute left-0 top-1/2 h-1/2 w-1 -translate-y-1/2 rounded-r-full bg-primary theme-glow-primary"></div>
|
||||
)}
|
||||
<Icon className={`h-5 w-5 shrink-0 transition-transform duration-300 ${isActive ? 'text-primary scale-110' : 'text-muted-foreground group-hover:text-foreground'}`} />
|
||||
{!isSidebarCollapsed && (
|
||||
@@ -99,10 +102,10 @@ export default function DashboardPage() {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-border/50 p-3 bg-card">
|
||||
<div className="theme-sidebar-footer p-3">
|
||||
<button
|
||||
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||
className={`hidden lg:flex w-full items-center rounded-xl p-2.5 text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground ${
|
||||
className={`hidden lg:flex w-full items-center rounded-xl border border-transparent p-2.5 text-muted-foreground transition-colors hover:bg-accent/50 hover:border-border/60 hover:text-foreground ${
|
||||
isSidebarCollapsed ? 'justify-center' : 'justify-start gap-3'
|
||||
}`}
|
||||
>
|
||||
@@ -119,9 +122,13 @@ export default function DashboardPage() {
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden relative">
|
||||
<div
|
||||
className={`relative flex flex-1 flex-col overflow-hidden transition-[margin] duration-300 ease-in-out ${
|
||||
isSidebarCollapsed ? 'lg:ml-[72px]' : 'lg:ml-64'
|
||||
}`}
|
||||
>
|
||||
{/* Top Header */}
|
||||
<header className="flex h-16 shrink-0 items-center justify-between border-b border-border/50 bg-background/80 px-4 backdrop-blur-md z-10">
|
||||
<header className="theme-sticky-bar flex h-16 shrink-0 items-center justify-between px-4 z-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -132,24 +139,49 @@ export default function DashboardPage() {
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-5 w-1.5 rounded-full bg-primary/80 hidden sm:block shadow-[0_0_8px_rgba(20,184,166,0.4)]"></div>
|
||||
<div className="h-5 w-1.5 rounded-full bg-primary/80 hidden sm:block theme-glow-primary"></div>
|
||||
<h1 className="text-lg font-semibold tracking-tight text-foreground">{currentTitle}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full border border-border/50 bg-muted/50">
|
||||
<div className="theme-control-pill hidden sm:flex">
|
||||
<div className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-success"></span>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-muted-foreground uppercase tracking-wider">System Online</span>
|
||||
<span className="font-mono uppercase tracking-wider">System Online</span>
|
||||
</div>
|
||||
<div className="h-6 w-px bg-border/50 hidden sm:block"></div>
|
||||
<div className="hidden md:flex items-center">
|
||||
<Select
|
||||
value={palette}
|
||||
onValueChange={(value) => {
|
||||
if (isColorPalette(value)) {
|
||||
setPalette(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="theme-interactive-elevate rounded-full border border-border/60 bg-muted/80 hover:bg-accent/60 transition-all h-9 w-9 p-0 justify-center [&>span]:hidden"
|
||||
title="切换配色方案"
|
||||
aria-label="切换配色方案"
|
||||
hideIndicator
|
||||
>
|
||||
<Palette className="h-4 w-4 text-muted-foreground" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="cobalt" description="默认 · 钴蓝冷静">Cobalt Blue</SelectItem>
|
||||
<SelectItem value="zinc" description="shadcn · 中性灰阶">Zinc Neutral</SelectItem>
|
||||
<SelectItem value="nord" description="Nord · Arctic Blue">Nord</SelectItem>
|
||||
<SelectItem value="tokyo-night" description="Tokyo Night · Neon Indigo">Tokyo Night</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full border border-border/50 bg-muted hover:bg-accent/50 transition-all h-9 w-9"
|
||||
className="theme-interactive-elevate rounded-full border border-border/60 bg-muted/80 hover:bg-accent/60 transition-all h-9 w-9"
|
||||
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
|
||||
title={resolvedTheme === 'dark' ? '切换为浅色主题' : '切换为深色主题'}
|
||||
>
|
||||
@@ -159,7 +191,7 @@ export default function DashboardPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full border border-border/50 bg-muted hover:bg-danger/10 hover:text-danger hover:border-danger/20 transition-all h-9 w-9"
|
||||
className="theme-interactive-elevate rounded-full border border-border/60 bg-muted/80 hover:bg-danger/10 hover:text-danger hover:border-danger/30 transition-all h-9 w-9"
|
||||
onClick={handleLogout}
|
||||
title="登出"
|
||||
>
|
||||
@@ -172,8 +204,8 @@ export default function DashboardPage() {
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 overflow-y-auto relative">
|
||||
<div className="absolute inset-0 bg-background/95 backdrop-blur-[1px] -z-10"></div>
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-[0.03] -z-10"></div>
|
||||
<div className={`mx-auto max-w-7xl animate-in fade-in slide-in-from-bottom-4 duration-500 ${(isConfigPage || isReviewConfigPage) ? '' : 'p-4 md:p-6 lg:p-8'}`}>
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-[0.025] -z-10"></div>
|
||||
<div className={`w-full animate-in fade-in slide-in-from-bottom-4 duration-500 ${(isConfigPage || isReviewConfigPage) ? '' : 'p-4 md:p-6 lg:p-8'}`}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function LoginPage() {
|
||||
} else {
|
||||
setError('登录失败,返回的 token 为空。');
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError('登录失败,请检查密码是否正确或查看服务日志。');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -29,28 +29,28 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen w-full items-center justify-center overflow-hidden bg-background">
|
||||
<div className="theme-shell-gradient relative flex min-h-screen w-full items-center justify-center overflow-hidden bg-background">
|
||||
{/* Background grid and gradient effects */}
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-10"></div>
|
||||
<div className="absolute top-[-20%] left-[-10%] h-[500px] w-[500px] rounded-full bg-primary/20 blur-[120px] pointer-events-none"></div>
|
||||
<div className="absolute bottom-[-20%] right-[-10%] h-[500px] w-[500px] rounded-full bg-primary/10 blur-[100px] pointer-events-none"></div>
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-[0.04]"></div>
|
||||
<div className="absolute top-[-25%] left-[-14%] h-[460px] w-[460px] rounded-full bg-primary/14 blur-[120px] pointer-events-none"></div>
|
||||
<div className="absolute bottom-[-26%] right-[-12%] h-[460px] w-[460px] rounded-full bg-accent/20 blur-[120px] pointer-events-none"></div>
|
||||
|
||||
<div className="z-10 w-full max-w-md px-4 sm:px-6 relative">
|
||||
<div className="glass-panel relative rounded-2xl p-8 sm:p-10 transition-all duration-500 hover:border-primary/20">
|
||||
<div className="theme-card-shell theme-interactive-elevate relative p-8 sm:p-10">
|
||||
{/* Decorative terminal dots */}
|
||||
<div className="absolute top-4 left-4 flex gap-2">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-danger/80 shadow-[0_0_5px_rgba(244,63,94,0.5)]"></div>
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-warning/80 shadow-[0_0_5px_rgba(245,158,11,0.5)]"></div>
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-success/80 shadow-[0_0_5px_rgba(16,185,129,0.5)]"></div>
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-danger/80 theme-glow-danger"></div>
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-warning/80 theme-glow-warning"></div>
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-success/80 theme-glow-success"></div>
|
||||
</div>
|
||||
|
||||
<div className="mb-10 mt-6 flex flex-col items-center text-center">
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-muted border border-primary/20 shadow-[0_0_20px_rgba(var(--primary),0.15)] ring-1 ring-primary/10 relative group">
|
||||
<div className="absolute inset-0 rounded-2xl bg-primary/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-muted/70 border border-primary/20 theme-glow-primary ring-1 ring-primary/10 relative group">
|
||||
<div className="absolute inset-0 rounded-2xl bg-accent/80 blur-md opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
<Bot className="h-8 w-8 text-primary relative z-10" />
|
||||
</div>
|
||||
<h1 className="mb-2 text-2xl font-bold tracking-tight text-foreground sm:text-3xl">Gitea AI Assistant</h1>
|
||||
<div className="flex items-center gap-2 text-xs font-mono text-primary/70 bg-primary/5 px-3 py-1 rounded-full border border-primary/10">
|
||||
<div className="theme-control-pill text-primary/80">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
||||
@@ -75,14 +75,14 @@ export function LoginPage() {
|
||||
onKeyDown={(e) => e.key === 'Enter' && !isLoading && handleLogin()}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
className="h-12 border-border bg-muted/50 font-mono text-foreground placeholder:text-muted-foreground/50 focus-visible:border-primary/50 focus-visible:ring-primary/20 transition-all duration-300"
|
||||
className="theme-input-surface h-12 font-mono placeholder:text-muted-foreground/50 transition-all duration-300"
|
||||
/>
|
||||
<Terminal className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground transition-colors group-focus-within:text-primary/70" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-danger/20 bg-danger/10 px-3 py-3 text-sm text-danger animate-in fade-in slide-in-from-top-1">
|
||||
<div className="theme-error-panel flex items-start gap-2 px-3 py-3 text-sm animate-in fade-in slide-in-from-top-1">
|
||||
<Activity className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<p className="font-mono text-xs leading-relaxed">{error}</p>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,22 @@ module.exports = {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
danger: {
|
||||
DEFAULT: "hsl(var(--danger))",
|
||||
foreground: "hsl(var(--danger-foreground))",
|
||||
},
|
||||
success: {
|
||||
DEFAULT: "hsl(var(--success))",
|
||||
foreground: "hsl(var(--success-foreground))",
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: "hsl(var(--warning))",
|
||||
foreground: "hsl(var(--warning-foreground))",
|
||||
},
|
||||
info: {
|
||||
DEFAULT: "hsl(var(--info))",
|
||||
foreground: "hsl(var(--info-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
|
||||
71
frontend/tests/visual/app.visual.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { installVisualApiMocks } from './fixtures/mockApi';
|
||||
import { applyThemeAndAuth, installVisualNetworkGuards, stabilizeVisualState, waitForThemeReady, type VisualPalette } from './fixtures/stabilize';
|
||||
|
||||
type VisualCase = {
|
||||
name: string;
|
||||
path: string;
|
||||
authToken?: string;
|
||||
readySelectors: string[];
|
||||
};
|
||||
|
||||
const protectedToken = 'visual-token';
|
||||
|
||||
const visualCases: VisualCase[] = [
|
||||
{
|
||||
name: 'login',
|
||||
path: '/',
|
||||
readySelectors: ['#password', 'button:has-text("AUTHORIZE")'],
|
||||
},
|
||||
{
|
||||
name: 'repos',
|
||||
path: '/repos',
|
||||
authToken: protectedToken,
|
||||
readySelectors: ['text=仓库名称', 'text=demo-repo-1'],
|
||||
},
|
||||
{
|
||||
name: 'config',
|
||||
path: '/config',
|
||||
authToken: protectedToken,
|
||||
readySelectors: ['button:has-text("保存配置")', 'text=Gitea 连接'],
|
||||
},
|
||||
{
|
||||
name: 'review-config',
|
||||
path: '/review-config',
|
||||
authToken: protectedToken,
|
||||
readySelectors: ['text=审查引擎', 'button:has-text("保存配置")'],
|
||||
},
|
||||
];
|
||||
|
||||
const themes: Array<'light' | 'dark'> = ['light', 'dark'];
|
||||
const palettes: VisualPalette[] = ['cobalt', 'zinc', 'nord', 'tokyo-night'];
|
||||
|
||||
for (const visualCase of visualCases) {
|
||||
for (const theme of themes) {
|
||||
for (const palette of palettes) {
|
||||
test(`${visualCase.name} ${theme} ${palette} baseline`, async ({ page }) => {
|
||||
await installVisualApiMocks(page);
|
||||
await installVisualNetworkGuards(page);
|
||||
await applyThemeAndAuth(page, theme, palette, visualCase.authToken);
|
||||
|
||||
await page.goto(visualCase.path, { waitUntil: 'networkidle' });
|
||||
await waitForThemeReady(page, theme, palette);
|
||||
|
||||
for (const selector of visualCase.readySelectors) {
|
||||
await page.locator(selector).first().waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
await stabilizeVisualState(page);
|
||||
|
||||
const snapshotName =
|
||||
palette === 'cobalt'
|
||||
? `${visualCase.name}-${theme}.png`
|
||||
: `${visualCase.name}-${theme}-${palette}.png`;
|
||||
|
||||
await expect(page).toHaveScreenshot(snapshotName, {
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 360 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 114 KiB |
335
frontend/tests/visual/fixtures/mockApi.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
|
||||
const repositories = {
|
||||
data: [
|
||||
{ name: 'demo-repo-1', webhook_status: 'active', hook_id: 101 },
|
||||
{ name: 'demo-repo-2', webhook_status: 'inactive', hook_id: null },
|
||||
],
|
||||
totalCount: 2,
|
||||
page: 1,
|
||||
limit: 30,
|
||||
};
|
||||
|
||||
const configResponse = {
|
||||
groups: [
|
||||
{
|
||||
key: 'gitea',
|
||||
label: 'Gitea 连接',
|
||||
description: '配置 Gitea 服务地址和访问凭据。',
|
||||
icon: 'git-branch',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'GITEA_BASE_URL',
|
||||
label: 'Gitea 地址',
|
||||
description: 'Gitea API 根地址',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
value: 'https://gitea.example.com',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'feishu',
|
||||
label: '飞书通知',
|
||||
description: '配置飞书 webhook 通知。',
|
||||
icon: 'bell',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'FEISHU_WEBHOOK_URL',
|
||||
label: '飞书 Webhook URL',
|
||||
description: '用于发送审查通知',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
value: 'https://open.feishu.cn/mock/webhook',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'security',
|
||||
label: '安全设置',
|
||||
description: '控制签名校验与访问策略。',
|
||||
icon: 'shield',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'ENABLE_SIGNATURE_VERIFY',
|
||||
label: '启用签名校验',
|
||||
description: '是否开启 webhook 签名验证',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
value: true,
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'review',
|
||||
label: '审查设置',
|
||||
description: '控制审查引擎与执行策略。',
|
||||
icon: 'search',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'REVIEW_ENGINE',
|
||||
label: '审查引擎',
|
||||
description: '当前使用的审查引擎',
|
||||
type: 'enum',
|
||||
enumValues: ['agent', 'codex'],
|
||||
sensitive: false,
|
||||
value: 'agent',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'GLOBAL_PROMPT',
|
||||
label: '全局提示词',
|
||||
description: '审查上下文提示词模板',
|
||||
type: 'text',
|
||||
sensitive: false,
|
||||
value: 'Review with focus on correctness and maintainability.',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_WORKDIR',
|
||||
label: '审查工作目录',
|
||||
description: '任务执行工作目录',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: '/tmp/review-workdir',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_MAX_PARALLEL_RUNS',
|
||||
label: '最大并发任务',
|
||||
description: '控制并发执行数量',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: 4,
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_MAX_FILES_PER_RUN',
|
||||
label: '单次最大文件数',
|
||||
description: '每轮审查最大文件数',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: 40,
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_MAX_FILE_CONTENT_CHARS',
|
||||
label: '单文件最大字符',
|
||||
description: '单文件读取上限',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: 20000,
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'LLM_MAX_CONCURRENT_CALLS',
|
||||
label: 'LLM 并发调用数',
|
||||
description: '限制并发调用',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: 4,
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'CODEX_MODEL',
|
||||
label: 'Codex 模型',
|
||||
description: 'Codex 模式默认模型',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: 'gpt-4o-mini',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'CODEX_API_URL',
|
||||
label: 'Codex API 地址',
|
||||
description: 'Codex 服务地址',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
value: 'https://api.openai.com/v1',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'memory',
|
||||
label: '记忆设置',
|
||||
description: '控制上下文记忆与保留策略。',
|
||||
icon: 'database',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'MEMORY_ENABLED',
|
||||
label: '启用记忆',
|
||||
description: '是否启用长期记忆',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
value: true,
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const providers = [
|
||||
{
|
||||
id: 'provider-openai',
|
||||
name: 'OpenAI',
|
||||
type: 'openai_responses',
|
||||
baseUrl: null,
|
||||
defaultModel: 'gpt-4o-mini',
|
||||
isEnabled: true,
|
||||
hasKey: true,
|
||||
extraConfig: {},
|
||||
createdAt: '2026-03-01T00:00:00.000Z',
|
||||
updatedAt: '2026-03-02T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'provider-deepseek',
|
||||
name: 'DeepSeek',
|
||||
type: 'openai_compatible',
|
||||
baseUrl: 'https://api.deepseek.com/v1',
|
||||
defaultModel: 'deepseek-chat',
|
||||
isEnabled: true,
|
||||
hasKey: true,
|
||||
extraConfig: {},
|
||||
createdAt: '2026-03-01T00:00:00.000Z',
|
||||
updatedAt: '2026-03-02T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const roles = [
|
||||
{
|
||||
role: 'planner',
|
||||
providerId: 'provider-openai',
|
||||
providerName: 'OpenAI',
|
||||
providerType: 'openai_responses',
|
||||
model: 'gpt-4o-mini',
|
||||
},
|
||||
{
|
||||
role: 'specialist',
|
||||
providerId: 'provider-deepseek',
|
||||
providerName: 'DeepSeek',
|
||||
providerType: 'openai_compatible',
|
||||
model: 'deepseek-chat',
|
||||
},
|
||||
];
|
||||
|
||||
const modelSuggestions = {
|
||||
openai_compatible: ['deepseek-chat', 'gpt-4o-mini'],
|
||||
openai_responses: ['gpt-4o', 'gpt-4o-mini', 'o3-mini'],
|
||||
anthropic: ['claude-sonnet-4-20250514'],
|
||||
gemini: ['gemini-2.5-pro'],
|
||||
};
|
||||
|
||||
const json = async (route: Route, body: unknown, status = 200) => {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
};
|
||||
|
||||
export async function installVisualApiMocks(page: Page) {
|
||||
await page.route('**/admin/api/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const path = url.pathname;
|
||||
const method = route.request().method();
|
||||
|
||||
if (method === 'POST' && path.endsWith('/admin/api/login')) {
|
||||
return json(route, { token: 'visual-token' });
|
||||
}
|
||||
|
||||
if (method === 'GET' && path.endsWith('/admin/api/repositories')) {
|
||||
return json(route, repositories);
|
||||
}
|
||||
|
||||
if (method === 'POST' && /\/admin\/api\/repositories\/[^/]+\/webhook$/.test(path)) {
|
||||
return json(route, { hook_id: 101, webhook_status: 'active' });
|
||||
}
|
||||
|
||||
if (method === 'DELETE' && /\/admin\/api\/repositories\/[^/]+\/webhook\/\d+$/.test(path)) {
|
||||
return route.fulfill({ status: 204, body: '' });
|
||||
}
|
||||
|
||||
if (method === 'GET' && path.endsWith('/admin/api/config')) {
|
||||
return json(route, configResponse);
|
||||
}
|
||||
|
||||
if (method === 'PUT' && path.endsWith('/admin/api/config')) {
|
||||
return route.fulfill({ status: 204, body: '' });
|
||||
}
|
||||
|
||||
if (method === 'POST' && path.endsWith('/admin/api/config/reset')) {
|
||||
return route.fulfill({ status: 204, body: '' });
|
||||
}
|
||||
|
||||
if (method === 'GET' && path.endsWith('/admin/api/llm/model-suggestions')) {
|
||||
return json(route, modelSuggestions);
|
||||
}
|
||||
|
||||
if (method === 'GET' && path.endsWith('/admin/api/llm/providers')) {
|
||||
return json(route, providers);
|
||||
}
|
||||
|
||||
if (method === 'POST' && path.endsWith('/admin/api/llm/providers')) {
|
||||
return json(route, providers[0]);
|
||||
}
|
||||
|
||||
if (method === 'PUT' && /\/admin\/api\/llm\/providers\/[^/]+$/.test(path)) {
|
||||
return json(route, providers[0]);
|
||||
}
|
||||
|
||||
if (method === 'DELETE' && /\/admin\/api\/llm\/providers\/[^/]+$/.test(path)) {
|
||||
return route.fulfill({ status: 204, body: '' });
|
||||
}
|
||||
|
||||
if (method === 'PUT' && /\/admin\/api\/llm\/providers\/[^/]+\/key$/.test(path)) {
|
||||
return route.fulfill({ status: 204, body: '' });
|
||||
}
|
||||
|
||||
if (method === 'DELETE' && /\/admin\/api\/llm\/providers\/[^/]+\/key$/.test(path)) {
|
||||
return route.fulfill({ status: 204, body: '' });
|
||||
}
|
||||
|
||||
if (method === 'GET' && path.endsWith('/admin/api/llm/roles')) {
|
||||
return json(route, roles);
|
||||
}
|
||||
|
||||
if (method === 'PUT' && /\/admin\/api\/llm\/roles\/[^/]+$/.test(path)) {
|
||||
return json(route, roles[0]);
|
||||
}
|
||||
|
||||
if (method === 'POST' && /\/admin\/api\/llm\/providers\/[^/]+\/test$/.test(path)) {
|
||||
return json(route, {
|
||||
success: true,
|
||||
latencyMs: 154,
|
||||
model: 'gpt-4o-mini',
|
||||
message: 'Test connection success',
|
||||
});
|
||||
}
|
||||
|
||||
return json(
|
||||
route,
|
||||
{
|
||||
error: `Unhandled visual mock request: ${method} ${path}`,
|
||||
},
|
||||
501
|
||||
);
|
||||
});
|
||||
}
|
||||
24
frontend/tests/visual/fixtures/screenshot.css
Normal file
@@ -0,0 +1,24 @@
|
||||
.animate-ping,
|
||||
.animate-pulse,
|
||||
.animate-spin {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', 'Helvetica Neue', Arial, sans-serif !important;
|
||||
}
|
||||
|
||||
code,
|
||||
pre,
|
||||
.font-mono {
|
||||
font-family: 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !important;
|
||||
}
|
||||
|
||||
.bg-grid-pattern {
|
||||
opacity: 0.04 !important;
|
||||
}
|
||||
63
frontend/tests/visual/fixtures/stabilize.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
export type VisualPalette = 'cobalt' | 'zinc' | 'nord' | 'tokyo-night';
|
||||
|
||||
const STABILIZE_STYLE = `
|
||||
*, *::before, *::after {
|
||||
transition-property: none !important;
|
||||
transition-duration: 0s !important;
|
||||
animation-duration: 0s !important;
|
||||
animation-delay: 0s !important;
|
||||
caret-color: transparent !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export async function stabilizeVisualState(page: Page) {
|
||||
await page.addStyleTag({ content: STABILIZE_STYLE });
|
||||
await page.evaluate(() => {
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
}
|
||||
|
||||
export async function installVisualNetworkGuards(page: Page) {
|
||||
await page.route('https://fonts.googleapis.com/**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/css',
|
||||
body: '',
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('https://fonts.gstatic.com/**', async (route) => {
|
||||
await route.fulfill({ status: 204, body: '' });
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForThemeReady(page: Page, theme: 'light' | 'dark', palette: VisualPalette) {
|
||||
await page.waitForFunction(({ expectedTheme, expectedPalette }) => {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const currentPalette = document.documentElement.getAttribute('data-palette') ?? 'cobalt';
|
||||
const themeReady = expectedTheme === 'dark' ? isDark : !isDark;
|
||||
return themeReady && currentPalette === expectedPalette;
|
||||
}, { expectedTheme: theme, expectedPalette: palette });
|
||||
}
|
||||
|
||||
export async function applyThemeAndAuth(
|
||||
page: Page,
|
||||
theme: 'light' | 'dark',
|
||||
palette: VisualPalette,
|
||||
authToken?: string
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ selectedTheme, selectedPalette, token }) => {
|
||||
localStorage.setItem('theme', selectedTheme);
|
||||
localStorage.setItem('ui-color-palette', selectedPalette);
|
||||
if (token) {
|
||||
localStorage.setItem('authToken', token);
|
||||
} else {
|
||||
localStorage.removeItem('authToken');
|
||||
}
|
||||
},
|
||||
{ selectedTheme: theme, selectedPalette: palette, token: authToken }
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from 'path';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { configDefaults, defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
@@ -12,5 +12,6 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
setupFiles: ['./src/test-setup.ts'],
|
||||
exclude: [...configDefaults.exclude, 'tests/visual/**'],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
"dev": "concurrently -k -p [{name}] -n backend,frontend -c blue,green \"bun run dev:backend\" \"bun run dev:frontend\"",
|
||||
"dev:backend": "bun run --watch src/index.ts",
|
||||
"dev:frontend": "cd frontend && bun run dev",
|
||||
"ui:regression": "cd frontend && bun run ui:regression",
|
||||
"ui:visual": "cd frontend && bun run ui:visual",
|
||||
"ui:visual:update": "cd frontend && bun run ui:visual:update",
|
||||
"build": "rm -rf dist && tsc",
|
||||
"start": "bun run src/index.ts",
|
||||
"start:prod": "bun run dist/index.js",
|
||||
|
||||