fix(ui): align card headers and stabilize themed layout polish

This commit is contained in:
jeffusion
2026-03-19 23:22:42 +08:00
committed by 路遥知码力
parent 1c0c9afd17
commit 28d86aff16
65 changed files with 1650 additions and 255 deletions

View File

@@ -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/

View 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 themesMIT
- 参考:<https://github.com/shadcn-ui/ui/blob/main/apps/v4/public/r/themes.css>
3. `nord`
- 来源NordMIT
- 参考:<https://github.com/nordtheme/nord>
4. `tokyo-night`
- 来源Tokyo NightApache-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
View File

@@ -22,3 +22,7 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Playwright
playwright-report/
test-results/

View File

@@ -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", "", {}, ""],
}
}

View File

@@ -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",

View 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,
},
});

View 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();

View File

@@ -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;

View File

@@ -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">

View File

@@ -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>;

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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>
))}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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" />

View File

@@ -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 ? '保存更改' : '已保存'}

View File

@@ -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>

View File

@@ -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>
)
}

View 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;
};

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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))",

View 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,
});
});
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View 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
);
});
}

View 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;
}

View 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 }
);
}

View File

@@ -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/**'],
},
});

View File

@@ -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",