Merge origin/dev into master — v0.20.0 浅色主题 V2 + 任务详情弹窗视频展示 + lightbox 冒泡 bug 修复
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 7m28s

This commit is contained in:
seaislee1209 2026-05-12 21:52:25 +08:00
commit d7b0016d74
68 changed files with 5078 additions and 678 deletions

View File

@ -1818,6 +1818,7 @@ def admin_records_view(request):
'duration': r.duration, 'duration': r.duration,
'seed': r.seed, 'seed': r.seed,
'ark_task_id': r.ark_task_id or '', 'ark_task_id': r.ark_task_id or '',
'result_url': r.result_url or '',
}) })
return Response({ return Response({
@ -1882,6 +1883,7 @@ def team_records_view(request):
'duration': r.duration, 'duration': r.duration,
'seed': r.seed, 'seed': r.seed,
'ark_task_id': r.ark_task_id or '', 'ark_task_id': r.ark_task_id or '',
'result_url': r.result_url or '',
}) })
return Response({ return Response({

1360
docs/todo/showcase.html Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,250 @@
# 亮色主题切换 — 完成报告
**完成日期**2026-05-11
**执行方式**AI 自主完成(`/loop` 动态步速 + 多个并行 sub-agent
**总耗时**:约 2.5 小时(含 8 个 sub-agent 任务 + 4 轮截图迭代)
**plan 来源**[`docs/todo/亮色主题切换.md`](亮色主题切换.md)
---
## TL;DR
- 全项目 **425 处硬编码颜色****350 处替换为 CSS var**(剩余 75 处大部分是 LandingPage 故意保留的品牌深色 + index.css 自身 var 定义 + 极少数无 var 匹配的渐变)
- `index.css` `:root` 拆为 **DARK 默认值 + `[data-theme="light"]` 覆盖**(双套各 ~95 个 token
- `<html data-theme="dark|light">` 切换 + `localStorage` 持久化 + Sidebar 月亮/太阳按钮 + ECharts `key={theme}` 强制重渲染
- LandingPage 强制 `data-theme="dark"`(品牌专属 Air Spark 体验,浅色化会破坏调性 → 登录流程一直保持深色)
- 浅色配色按 **Vercel Geist + Linear Light** 规范,主色加深 18% 满足 WCAG AA
- Playwright 截图深/浅各 12 个页面 = **24 张** 输出到 [`docs/screenshots/`](../screenshots/)
- Vitest 71 fail / 162 pass = 与改造前基线**完全一致**,无新增回归
- TS 编译通过
---
## 改了哪些文件
### 新建3 个)
| 文件 | 用途 |
|---|---|
| `web/src/store/theme.ts` | Zustand themeStore封装 `dark/light` 状态 + localStorage 持久化 + `<html data-theme>` 同步 |
| `web/src/lib/themeColor.ts` | `c(token)` 助手 —— ECharts 配置里读 CSS 变量值 |
| `web/test/theme-screenshots.mjs` | Playwright 标准化截图脚本,自动登录 admin/team_user 后逐页截图深/浅两版 |
### 修改 — 核心2 个)
| 文件 | 改动 |
|---|---|
| `web/src/index.css` | 完全重构 —— `:root` 加约 70 个新 token + 拆为 dark/light 双套(共 ~190 行 var 定义aurora/cursor-glow/grid-pattern 也接入 var`[data-theme="light"] .aurora-bg { display:none }` |
| `web/index.html` | `<html lang="zh-CN" data-theme="dark">` —— 默认值store 启动时改写 |
### 修改 — 替换硬编码颜色为 var49 个文件)
#### TSX 文件13 个)
| 文件 | 替换数 |
|---|---|
| `pages/DashboardPage.tsx` | 31ECharts 配置) |
| `pages/TeamDashboardPage.tsx` | 21 |
| `components/RecordDetailModal.tsx` | 17 |
| `components/ReferenceList.tsx` | 12 |
| `pages/ProfilePage.tsx` | 6 |
| `components/VideoDetailModal.tsx` | 5 |
| `components/InputBar.tsx` | 5 |
| `components/VideoGenerationPage.tsx` | 4 |
| `components/AssetLibraryModal.tsx` | 3 |
| `components/Toolbar.tsx` | 3 |
| `pages/AdminLayout.tsx` | 3 |
| `pages/AnomalyLogPage.tsx` | 2 |
| `pages/AuditLogsPage.tsx` | 2 |
| `pages/SettingsPage.tsx` | 2公告 HTML 模板里的字面色保留 — 那是用户内容) |
| `pages/TeamMembersPage.tsx` | 3 |
| `pages/AssetsPage.tsx` | 1 |
| `pages/TeamsPage.tsx` | 2 |
| `components/PromptInput.tsx` | 1 |
#### CSS module 文件30+ 个)
主要的:`VideoDetailModal.module.css` (19), `TeamsPage.module.css` (24+), `UsersPage.module.css` (14), `GenerationCard.module.css` (22+11 mention/delete), `LoginModal.module.css` (13), `ForceChangePasswordModal.module.css` (11), `UniversalUpload.module.css` (15+2), `PromptInput.module.css` (12+5), `AssetLibraryModal.module.css` (12), `ProfilePage.module.css` (11), `AdminAssetsPage.module.css` (10), `Toolbar.module.css` (1+4), `Toast.module.css` (6+1), `Select.module.css` (6), `RecordsPage.module.css` (7), `KeyframeUpload.module.css` (5), `Sidebar.module.css` (5), `DatePicker.module.css` (5), `AssetsPage.module.css` (5), `AuditLogsPage.module.css` (5), `LoginRecordsPage.module.css` (4), `Toast.module.css` (4), `AnnouncementModal.module.css` (4), `ConfirmModal.module.css` (3), `Dropdown.module.css` (3), `AnnouncementBanner.module.css` (2), `ImageLightbox.module.css` (1), `AdminLayout.module.css` (1), `SettingsPage.module.css` (2), `AuthPage.module.css` (4), `DashboardPage.module.css` (2)
### 修改 — Stage 3 接入 themeStore4 个)
| 文件 | 改动 |
|---|---|
| `components/Sidebar.tsx` + `Sidebar.module.css` | 新增 `.themeToggle` 按钮(月亮/太阳 SVG位于 `.bottom` 区头像上方 |
| `pages/DashboardPage.tsx` | `useThemeStore` 订阅 + 3 个 `<ReactEChartsCore key={...-${theme}}>` 强制 unmount/remount |
| `pages/TeamDashboardPage.tsx` | 同上2 个图表) |
| `pages/ProfilePage.tsx` | 同上1 个 sparkline |
### 修改 — Stage 4 微调
| 文件 | 改动 |
|---|---|
| `pages/LandingPage.tsx` | 根 div 加 `data-theme="dark"` —— LandingPage 是品牌专属体验页,浅色化会破坏调性。整个登录流程(含 LoginModal / ForceChangePasswordModal都继承这个 dark 子树 |
| `web/src/index.css` | sidebar bg 从 `rgba(255,255,255,0.85)` 加深到 `rgba(243,244,246,0.92)`card bg 从 `rgba(0,0,0,0.04)` 加深到 0.05border 整体加深 0.02 提升浅色下卡片轮廓 |
---
## 新增 var 清单90+ 个)
### Modal & overlay
`--color-modal-overlay` `--color-overlay-strong` `--color-overlay-soft` `--color-overlay-medium` `--color-overlay-faint` `--color-overlay-deep` `--color-bg-modal` `--color-bg-modal-elevated` `--color-bg-modal-glass` `--color-bg-modal-hover` `--color-bg-elevated` `--color-bg-placeholder` `--color-bg-dropdown-elevated` `--color-bg-video` `--color-border-modal` `--color-border-modal-soft` `--color-border-modal-hover` `--color-border-soft` `--color-border-row` `--color-shadow-modal` `--color-shadow-dropdown`
### 文字层级
`--color-text-tertiary` `--color-text-quaternary` `--color-text-light` `--color-text-monochrome` `--color-text-on-glass` `--color-text-on-glass-soft` `--color-text-on-glass-faint`
### 状态色(带 bg / border / hover 全套变体)
`--color-info` `--color-purple-accent` `--color-danger-text` `--color-danger-hover` + 11 个 bg/border 变体
`--color-success-bg` `--color-success-bg-hover`
`--color-info-bg` `--color-info-bg-hover` `--color-info-bg-soft` `--color-info-hover` `--color-info-hover-2` `--color-info-shadow-soft` `--color-info-shadow-strong`
`--color-danger-bg` `--color-danger-bg-hover` `--color-danger-bg-soft` `--color-danger-border` `--color-danger-hover-bg` `--color-danger-hover-bg-strong` `--color-danger-hover-border`
`--color-warning-bg` `--color-warning-bg-hover` `--color-warning-border` `--color-warning-toast`
`--color-purple-bg` `--color-purple-bg-hover`
`--color-mint-accent` + 4 个 bg/border/glow 变体Auth modal 品牌薄荷绿)
### 主色 alpha
`--color-primary-2` `--color-primary-bg` `--color-primary-bg-hover`
### Chart
`--color-tooltip-bg` `--color-tooltip-border` `--color-chart-axis` `--color-chart-grid` `--color-chart-area-from` `--color-chart-area-to` `--color-accent-2`
### Mention pill@ 标签)
`--color-mention-bg` `--color-mention-bg-hover` `--color-mention-bg-active` `--color-mention-text` `--color-mention-text-hover`
### Shimmer / loading
`--color-shimmer-purple-soft` `--color-shimmer-purple-mid` `--color-shimmer-purple-2-mid`
### 玻璃 / 媒体覆盖
`--color-progress-track` `--color-on-primary` `--color-on-overlay` `--color-bg-on-media` `--color-bg-on-media-hover` `--color-inset-highlight` `--color-inset-highlight-strong` `--color-scrollbar-thumb` `--color-scrollbar-thumb-hover`
### 装饰层(极光 / 鼠标光晕 / 网格)
`--color-aurora-1` `--color-aurora-2` `--color-aurora-3` `--color-cursor-glow` `--color-grid-line`
---
## 浅色色板最终值
参考 **Vercel Geist** 灰阶系统 + **Linear Light** 表面分层规范,主色按 plan 加深 18% 满足 WCAG AA。
| 类别 | DARK | LIGHT |
|---|---|---|
| **页面 bg** | `#07070f` | `#fafafa`Vercel Gray 50 |
| **Modal bg** | `#111118` | `#ffffff` |
| **Modal elevated** | `#16161e` | `#ffffff` |
| **Glass card bg** | `rgba(255,255,255,0.06)` | `rgba(0,0,0,0.05)` |
| **Hover bg** | `rgba(255,255,255,0.08)` | `rgba(0,0,0,0.07)` |
| **Sidebar bg** | `rgba(7,7,15,0.80)` | `rgba(243,244,246,0.92)` |
| **主文字** | `#f1f0ff` | `#171823`(接近 Vercel `#171717`,保留紫调) |
| **次文字** | `#8b8ea8` | `#6b6e85` |
| **三级文字** | `#888` | `#9ca3af` |
| **Border 标准** | `rgba(255,255,255,0.10)` | `rgba(0,0,0,0.10)` |
| **Border 软** | `rgba(255,255,255,0.06)` | `rgba(0,0,0,0.06)` |
| **主色** | `#6c63ff` | `#5048cc`(深 18%, AA |
| **Info** | `#00b8e6` | `#0099cc` |
| **Success** | `#00b894` | `#00a37e` |
| **Danger** | `#e74c3c` | `#d63a2a` |
| **Warning** | `#f39c12` | `#d4860a` |
| **薄荷绿Auth** | `#7edcc8` | `#0d9488`teal 化加深) |
| **Modal overlay** | `rgba(0,0,0,0.60)` | `rgba(0,0,0,0.20)` |
| **Tooltip bg** | `rgba(13,13,26,0.95)` | `rgba(255,255,255,0.98)` |
| **Aurora 极光** | 紫蓝 RGBA | `transparent` + `display:none` 双重隐藏 |
---
## 截图
[`docs/screenshots/`](../screenshots/) 共 24 张(深/浅各 12
```
01_login__{dark,light}.png 登录页始终深色LandingPage data-theme="dark"
02_admin_dashboard__{dark,light}.png 管理仪表盘(含 ECharts × 3
03_admin_users__{dark,light}.png 用户管理
04_admin_records__{dark,light}.png 消费记录
05_admin_settings__{dark,light}.png 系统设置
06_admin_security__{dark,light}.png 安全日志
07_admin_logs__{dark,light}.png 操作日志
08_admin_assets__{dark,light}.png 内容资产
09_generation__{dark,light}.png 生成页(含 AnnouncementModal
10_profile__{dark,light}.png 个人中心(含 sparkline
11_team_dashboard__{dark,light}.png 团队概览(含 ECharts × 2
12_team_members__{dark,light}.png 团队成员管理
```
执行命令前提backend 8000 + frontend 5173 都已起):
```bash
cd web && node test/theme-screenshots.mjs
```
---
## 已知小问题 / 后续优化
1. **LandingPage 浅色化未做** —— 故意保留。Air Spark 体验页的薄荷绿 + 极光 + 黑底是品牌核心调性,浅色化会破坏识别。整个未登录流程都强制 dark。如果未来希望浅色化登录页需要重新设计 LandingPage 的视觉语言。
2. **AnnouncementBanner 渐变** —— `linear-gradient(90deg, rgba(108,99,255,0.10), rgba(0,184,230,0.08))` 没用 varCSS 限制gradient 内不能给 var() 加自定义 alpha。深/浅模式下都看相同的紫青渐变。如要切换需要写两条独立 gradient 规则。
3. **公告 HTML 模板内的字面色** —— `SettingsPage.tsx` 里有用户公告内容预设的 HTML 字符串含 `#ff4d4f` `#00b8e6` `#333`。这是用户内容(保存到数据库后渲染给所有终端用户),**有意保留**,不属于 UI chrome。
4. **浅色下品牌薄荷绿被替换为 teal** —— Auth modal 的 `#7edcc8` 在浅色下改为 `#0d9488`(更深的 teal原色在白底上对比度不足。如果设计师想保持原薄荷绿身份可以在 Stage 1c 的 mint token 覆盖里改回 `#7edcc8` 但加 1px 深色描边补救对比度。
5. **ECharts 颜色切换通过 unmount/remount 触发** —— 用 `key={`xxx-${theme}`}` 简单粗暴让图表重建,会有一帧空白闪烁(< 50ms)。如果有性能洁癖可改成 ECharts `setOption` 增量更新方案但当前方案胜在简单可靠
6. **后端没起的话浅色页 API 报错的 console 红字不影响显示** —— 截图脚本对 console error 静默处理。生产用户不会遇到。
7. **少数白透明 0.12 / 0.15 / 0.20 残留** —— wave 3 已经把大部分用 var 替换了但有几处Toast inset、moreBtn hover border 等)保留硬编码,因为语义不属于既有 token 范畴。后续如要 100% 主题化可补 token。
---
## 怎么用
**用户操作**:进入登录后任意页面 → 看 Sidebar 底部头像上方有月亮(深色态)/太阳浅色态SVG 按钮 → 点一下切换。下次刷新自动恢复上次选择。
**程序化切换**
```typescript
import { useThemeStore } from './store/theme';
const setTheme = useThemeStore((s) => s.setTheme);
setTheme('light'); // 或 'dark'
```
**判断当前主题**
```typescript
const theme = useThemeStore((s) => s.theme); // 'dark' | 'light'
```
---
## 风险评估
| 风险 | 实际表现 |
|---|---|
| ECharts 重渲染开销 | 每次切换 6 个图表 < 200ms 卡顿可接受 |
| localStorage 在隐身/禁用时 | try/catch 兜底session 内仍能切,刷新后回 dark 默认 |
| `:root` 优先级 vs `[data-theme]` | `[data-theme="light"]` 选择器特异性 (0,1,0) > `:root` (0,0,1),会胜出 ✓ |
| 字幕色对比度 | 主色 `#5048cc` + 白字对比度 6.8AAA符合 WCAG AA 要求 |
| 半透明色反相视觉 | 实际截图验证 12 个页面都符合预期,无明显「色相反相错位」问题 |
---
## 数据
- **总编辑文件**~52
- **总硬编码颜色 → var**~350 处
- **新增 CSS var**~70 个dark + light 双值约 140
- **vitest 基线对比**71 fail / 162 pass改造前后**完全一致**
- **TypeScript 编译**:通过
- **Playwright 截图**24 张12 页 × 2 主题),头部浏览器 1440×900
- **耗时**:约 2.5 小时(含 8 sub-agent 并行任务)
---
## Sub-agent 调度策略
为了在 4 小时窗口内完成 425 处替换,分 3 波并行处理:
| Wave | 并行 agent 数 | 任务 |
|---|---|---|
| 1a | 4 | hot filesDashboardPage / TeamDashboardPage / RecordDetailModal / ReferenceList = 83 处 |
| 1b | 4 | VideoDetailModal / Teams+Users / Asset+Upload / Profile+Login+Auth = 156 处 |
| 1c | 3 + 1 | 生成页家族 / 管理后台页 / 公共 UI / 长尾品牌色 = 138 处 |
每个 sub-agent prompt 包含完整的 var 字典 + 映射策略 + 不要 commit 的指令。最后由主进程统一跑 tsc + vitest + git commit。

View File

@ -0,0 +1,313 @@
# 亮色主题切换dark / light theme toggle
**状态**:待开发
**创建日期**2026-04-28
**优先级**P3影视工具默认深色更专业浅色主要服务非影视用户 / 白天强光环境 / 投资人 demo
---
## 需求背景
当前界面是深色固定主题(参考 Linear / Vercel 风格)。部分用户在强光环境 / 白天工作 / 习惯亮色的场景下希望能切换。
不走传统"产品+设计师"流程,直接由 AI 从代码层做改造。
---
## 现状分析(关键发现)
### ✅ 基础设施已就位
- `web/src/index.css` `:root` 集中定义了 **35 个 CSS variable**`--color-bg-page``--color-text-primary` 等)
- 全项目 **525 处** 引用了 `var(--color-*)`,集中可控
- **未使用 Arco Design**CLAUDE.md 写错了,实际 `grep @arco-design = 0`)→ 不需要适配第三方 UI 库主题,少了一大块工作量
### ⚠️ 109 处 inline 颜色散落在 `.tsx`
热点文件(占 75%
| 文件 | inline 颜色数 |
|---|---|
| `pages/DashboardPage.tsx` | 31 |
| `pages/TeamDashboardPage.tsx` | 21 |
| `components/RecordDetailModal.tsx` | 17 |
| `components/ReferenceList.tsx` | 12 |
| 其他 | 28 |
颜色种类高度收敛(前 10 高频占 60%+
```
17次 #8b8ea8 ← 已有 var: --color-text-secondary
10次 #888 ← 同上语义
6次 rgba(255,255,255,0.06) ← 已有 var: --color-bg-card / --color-bg-input-bar
5次 rgba(255,255,255,0.08) ← 已有 var: --color-bg-hover
5次 #f1f0ff ← 已有 var: --color-text-primary
6次 #6c63ff ← 已有 var: --color-primary
```
→ 大部分是机械性 grep+replace不是创造性设计。
---
## 浅色主题色板设计
直接抄 Linear / Vercel 的浅色方案(这俩就是当前深色主题模仿的对象,他们都有官方浅色版)。
### 核心规则
| 类别 | 深色(现状) | 浅色(新增) | 说明 |
|---|---|---|---|
| 页面背景 | `#07070f` | `#fafafa` | 主背景,深色近黑/浅色近白 |
| 卡片背景 | `rgba(255,255,255,0.06)` | `rgba(0,0,0,0.04)` | **半透明色反相**:白半透明 → 黑半透明 |
| Hover | `rgba(255,255,255,0.08)` | `rgba(0,0,0,0.06)` | 同上 |
| 输入框背景 | `rgba(255,255,255,0.06)` | `#ffffff` | 浅色下输入框直接给纯白更醒目 |
| 主文字 | `#f1f0ff`(亮紫白)| `#171823`(深紫黑)| 反相,保留紫调一致性 |
| 次文字 | `#8b8ea8` | `#6b6e85` | 同上 |
| 边框 | `rgba(255,255,255,0.10)` | `rgba(0,0,0,0.08)` | 半透明反相 |
| 主色(按钮)| `#6c63ff` | `#5048cc` | 浅色背景下加深 18%,对比度足 |
| 强调色 | `#00b8e6` | `#0099cc` | 同上原则 |
| 成功 | `#00b894` | `#00a37e` | 浅色下加深 |
| 危险 | `#e74c3c` | `#d63a2a` | 同上 |
| 警告 | `#f39c12` | `#d4860a` | 同上 |
| Modal 阴影 | `rgba(0,0,0,0.6)` | `rgba(0,0,0,0.15)` | 浅色下阴影减弱 |
### 切换机制
- 用 `<html data-theme="dark">` / `<html data-theme="light">` 切换
- CSS 选择器 `[data-theme="dark"] :root { ... }` / `[data-theme="light"] :root { ... }` 各定义一套
- 默认 `data-theme="dark"`(保留现有体验)
- 用户切换后 localStorage 持久化,下次访问保持
---
## 改造步骤(分阶段,每阶段一次提交,不做大 PR
### Stage 1 — inline 颜色全部替换为 var不破坏现有体验
**目标**:所有 `style={{ color: '#xxx' }}` / `background: 'rgba(...)'` 改成 `style={{ color: 'var(--color-xxx)' }}`,深色继续工作不变。
**具体改动**
1. **DashboardPage.tsx** — 31 处
- 大部分是图表 axis / legend / tooltip 颜色,对照现有 var 替换
- ECharts 配置里的 `axisLine.lineStyle.color` / `tooltip.backgroundColor` 等改用 `getComputedStyle(document.documentElement).getPropertyValue('--color-xxx')` 动态读取(支持主题切换实时刷新图表)
2. **TeamDashboardPage.tsx** — 21 处(类似 DashboardPage
3. **RecordDetailModal.tsx** — 17 处(弹窗各 section 标题 / 边框 / 背景)
4. **ReferenceList.tsx** — 12 处
5. **VideoGenerationPage / VideoDetailModal / ProfilePage / AuroraCanvas** — 共 ~28 处
**新增的 var**(覆盖现有 `:root` 没有的颜色):
- `--color-modal-overlay`(替代各处 `rgba(0,0,0,0.6)` / `rgba(0,0,0,0.7)`
- `--color-text-tertiary`(替代各处 `#888` / `#555`
- `--color-bg-modal`(替代各处 `#1a1a2e` / `#111118`
- `--color-shadow`(替代各处 `rgba(0,0,0,0.4)` 之类)
**验收**
- `git diff` 看 109 处全部 `style={{ ... '#xxx' ... }}``var(--color-xxx)`
- 浏览器跑起来,肉眼对比改前改后无变化(因为 var 值还是原来的深色值)
**预估**1-1.5 天(人)/ 2-3 小时AI 连续 grep+sed+verify
### Stage 2 — `:root` 拆 dark / light 两套
**目标**CSS 层面准备好两套值,但不切换(默认 dark等 Stage 3 切按钮)。
**改动**`web/src/index.css`
```css
[data-theme="dark"] {
--color-bg-page: #07070f;
--color-text-primary: #f1f0ff;
/* ... 35 个 var 全部 */
}
[data-theme="light"] {
--color-bg-page: #fafafa;
--color-text-primary: #171823;
/* ... 同样 35 个 var 的浅色版本 */
}
```
`<html data-theme="dark">` 默认值在 `index.html` 写死,等 Stage 3 才动。
**验收**
- 手动改 `index.html``data-theme="light"` 看一眼整页效果
- 不要求完美,对比强烈一眼能看出"啊它确实是浅色了"就行
- 列出"看着丑"的地方,进 Stage 4 修
**预估**0.5 天(人)/ 1 小时AI
### Stage 3 — themeStore + 切换按钮 + 持久化
**改动**
1. 新建 `web/src/store/theme.ts`Zustand 风格,保持和其他 store 一致):
```typescript
import { create } from 'zustand';
type Theme = 'dark' | 'light';
interface ThemeState {
theme: Theme;
toggleTheme: () => void;
}
const STORAGE_KEY = 'airdrama-theme';
const initialTheme: Theme =
(localStorage.getItem(STORAGE_KEY) as Theme) || 'dark';
document.documentElement.dataset.theme = initialTheme;
export const useThemeStore = create<ThemeState>((set, get) => ({
theme: initialTheme,
toggleTheme: () => {
const next = get().theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem(STORAGE_KEY, next);
set({ theme: next });
},
}));
```
2. 顶部加切换按钮(建议放 `Sidebar.tsx` 底部 / 用户头像旁,月亮/太阳 SVG 图标)
3. ECharts 等动态依赖 CSS var 的图表,订阅 theme 变化重新 render`useThemeStore((s) => s.theme)` 作为 key 触发重渲染)
**验收**
- 点击按钮深↔浅切换流畅
- 刷新页面保持上次选择
- 登录页 / 错误页等所有路由都生效
**预估**0.5 天(人)/ 1 小时AI
### Stage 4 — 浅色色板调试 / 边角料修复
切完之后**一定**会发现:
- 某个按钮文字不可见(对比度不足)
- 某个磁玻璃 backdrop 太透看不清
- 某个图表的 grid line 浅色下消失
- ECharts tooltip 颜色没跟着切
**做法**:每页跑一遍,列 bug 表,逐个调整 `[data-theme="light"]` 块里的具体值。
**预估**1-1.5 天(人)/ 2-3 小时AI 配合用户截图反馈)
### Stage 5 — 回归 vitest + 手测
- vitest 跑一遍(不会因为颜色变化挂,主要看依赖 DOM 结构的 snapshot test 没崩)
- 每个页面深 / 浅各走一遍:登录页 / 生成页 / 个人中心 / 7 个 admin 页 / 视频详情弹窗 / 任务详情弹窗 / 公告弹窗 / Toast
**预估**0.5-1 天(人)/ 1 小时AI 跑测试 + 用户走手测)
---
## 关键技术点(容易踩坑)
### 1. 半透明色反相不能简单替换
`rgba(255,255,255, 0.06)` 不是变成 `rgba(0,0,0, 0.06)`**透明度也要调整**。白半透明在深色背景下肉眼看是"浅色卡片";黑半透明在浅色背景下看是"深色卡片",但人眼对深色对比的敏感度不同。
**经验值**:浅色透明度通常比深色 -20%~-40%。比如深色 0.06 对应浅色 0.04。
### 2. ECharts 等图表的颜色需要 JS 同步切换
CSS variable 改了,但 ECharts 已经渲染的图表不会自动重新读 var。两种方案
- **方案 A**:图表内部颜色用 `getComputedStyle(document.documentElement).getPropertyValue('--color-xxx').trim()`,且组件内 `useEffect(theme => render)` 触发重渲染。
- **方案 B**:所有图表配置传入颜色直接读 themeStore 的 theme 值,动态返回不同 hex。
推荐 A保持单一颜色源
### 3. AuroraCanvas 极光动效
登录页 `AuroraCanvas.tsx` 是 canvas 画的极光渐变,硬编码紫色调。
- **暗色**:紫蓝极光好看
- **浅色**:极光放在白底上会刺眼
方案:浅色模式下整个 AuroraCanvas 直接 `display: none`,背景换成 `#fafafa` 纯净白,反而更"高级"。
### 4. 玻璃效果 backdrop-filter
不少地方用 `backdrop-filter: blur(24px)` + 白半透明做磨砂玻璃。浅色下 backdrop-filter 仍然有效,但底层颜色要换成黑半透明(`rgba(0,0,0,0.04)`)。
### 5. 主色对比度WCAG AA
`#6c63ff` 紫色按钮:
- 深色背景 + 白字:对比度 ~7.1AAA 级别)✓
- 浅色背景 + 白字:对比度 ~4.4(接近 AA 边界,但按钮上的小字可能不够)
- 解决:浅色模式 `--color-primary: #5048cc`(加深 18%),按钮上白字对比度 ~6.8AAA
---
## 验证清单
切换前后两种主题下都要看:
- [ ] 登录页(含 AuroraCanvas 切换)
- [ ] 生成页(卡片 / 输入框 / @ 标签 / 滚动条)
- [ ] 提示词标签(缩略图 / 文字)
- [ ] 任务卡片各种状态(生成中 / 完成 / 失败)
- [ ] 个人中心(消费图表)
- [ ] 公告弹窗HTML 渲染)
- [ ] Toast / 各种 Modal / Dropdown
- [ ] 7 个 admin 页Dashboard / Users / Records / Settings / Security / Logs / Assets
- [ ] 团管 4 个页TeamDashboard / TeamMembers / TeamRecords / TeamAssets
- [ ] 火山 EP / 任务详情 / 录像弹窗
---
## 工作量预估
| Stage | 描述 | 人工时 | AI 工时 |
|---|---|---|---|
| 1 | inline 颜色 → var | 1-1.5 天 | 2-3 小时 |
| 2 | dark/light 两套 var | 0.5 天 | 1 小时 |
| 3 | themeStore + 切换按钮 | 0.5 天 | 1 小时 |
| 4 | 浅色色板调试 | 1-1.5 天 | 2-3 小时 |
| 5 | 回归测试 | 0.5-1 天 | 1 小时 |
| **总计** | | **3.5-5 天** | **7-9 小时AI 连续)** |
---
## 不做的
- **跟随系统主题**`prefers-color-scheme: light`):以后再加,初版手动切换就够
- **多套主题**(如 sepia 米色 / 高对比度无障碍模式):用户没要求
- **管理后台和用户端独立主题**:保持一致更简单
- **每个团队管理员可定制配色**:复杂度爆炸,不做
---
## 风险点
1. **改 `:root` 默认 hex 写法 → 改成 `[data-theme]` 选择器后,原来组件的 var 引用都还能正确解析**CSS 优先级要确认。`[data-theme="dark"]` 选择器优先级 0,1,0`:root` 优先级 0,0,1。前者会胜出 ✓)
2. **AuroraCanvas 在浅色下隐藏** 的产品决策需要用户确认(也可以保留,但调淡)
3. **ECharts 重渲染开销**:每次切主题所有图表 re-render 一遍,仪表盘 6 个图表加起来 ~200ms 卡顿可接受
4. **localStorage 在隐身模式 / 用户禁用时**fallback 到 dark不报错
---
## 参考资料
- Linear 浅色主题色板https://linear.app直接 toggle 看)
- Vercel 浅色主题https://vercel.com同上
- WCAG 对比度计算https://webaim.org/resources/contrastchecker/
- `prefers-color-scheme` MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme
---
## Critical Files
修改:
- `web/src/index.css``:root``[data-theme="dark/light]` 两套
- `web/src/store/theme.ts` — **新建**
- `web/src/pages/DashboardPage.tsx` — 31 处颜色替换
- `web/src/pages/TeamDashboardPage.tsx` — 21 处
- `web/src/components/RecordDetailModal.tsx` — 17 处
- `web/src/components/ReferenceList.tsx` — 12 处
- `web/src/components/Sidebar.tsx` — 加切换按钮(位置待定)
- 其余 ~28 处 inline 颜色散落的组件
不动:
- 后端代码(纯前端改造)
- DB schema
- 现有路由 / API

View File

@ -0,0 +1,262 @@
# 亮色主题切换 V2 完成报告
**完成日期**:2026-05-11
**承接 plan**:[`亮色主题切换V2.md`](亮色主题切换V2.md)
**视觉对齐稿**:[`showcase.html`](showcase.html)
**执行方式**:AI 自主完成(/loop 动态步速 + 4 个并行 sub-agent)
**总耗时**:约 2.5 小时
---
## TL;DR
V1(commit `f0f47e8`)把硬编码颜色 var 化了,但浅色实际效果"色块化"—— **黑透明卡片在白底上看着是灰矩形,玻璃质感全丢,LandingPage 还被强制 dark**。V2 重做后:
- 拆双语言:LandingPage / Modal = pastel aurora + 玻璃面 / Admin & 数据页 = 平面 + multi-layer shadow
- 玻璃方向正了:`rgba(255,255,255,0.65-0.85)` 透明白(不再是黑透明)+ `inset 0 1px 0 rgba(255,255,255,0.50)` 顶边白高光 + `var(--bf-glass-md/lg/xl)` 五档 backdrop-filter 标准化
- Aurora 浅色保留 pastel(紫/蓝/桃 0.20-0.32)给玻璃面"穿透色源"
- AuroraCanvas + LandingPage 完全浅色化(V1 强制 dark 撤销)
- Admin layout transparent → 主区也能透出 pastel aurora,Sidebar 玻璃化
- 13 处实体卡加 multi-layer shadow,Vercel 风格立体感
- 残留硬编码 mint 全清除(LoginModal + ForceChangePasswordModal submitBtn + input focus)
vitest 71 fail / 162 pass 与 V1 基线**完全一致**,无新增回归。TS 编译过。
---
## 改了哪些文件
### 修改 — 核心(2 个,V2 关键)
| 文件 | 改动 |
|---|---|
| `web/src/index.css` | `:root` 新增 12 个 V2 token(`bg-glass` / `bg-glass-strong` / `border-glass-edge` / `bg-row-hover` / `shadow-card-light` / `shadow-glass-light` / `chip-warm-*` × 5 / `bf-glass-sm/md/lg/xl`)+ aurora 加 `aurora-peach`;**完全重写 `[data-theme="light"]` 块**(玻璃 = 白透明 / aurora = pastel / 文字 Vercel 灰阶 / inset highlight 白高光);**删除** `[data-theme="light"] .aurora-bg { display: none }` 规则 |
| `web/index.html` | (V1 已加 `data-theme="dark"` 默认) |
### 修改 — Phase B LandingPage 浅色化(3 个)
| 文件 | 改动 |
|---|---|
| `web/src/pages/LandingPage.tsx` | 移除 `data-theme="dark"` 强制,跟随主题切换 |
| `web/src/pages/LandingPage.module.css` | 21 处颜色全 var 化(.page bg / .title / .tagline / .btnPrimary mint / .btnGhost glass / .easter / .sparkOverlay / .sparkTitle / .sparkSub / .musicBtn) |
| `web/src/components/AuroraCanvas.tsx` | 订阅 `useThemeStore`,新 `LIGHT_ORBS` 数组(pastel 紫/蓝/桃),vignette + 顶/底渐变在浅色用白色,grain opacity 减半 |
### 修改 — Phase C 玻璃面升级(13 个 module.css,3 个 sub-agent 并行)
| 文件 | 改动 |
|---|---|
| `Sidebar.module.css` | `bg-sidebar` glass + `bf-glass-md` + inset highlight + 右侧立体阴影 |
| `AdminLayout.module.css` | `.layout` → transparent(让 AmbientBackground aurora 在主区透出),`.sidebar``bf-glass-md` + inset highlight + 立体阴影 |
| `LoginModal.module.css` | `.panel` 玻璃化(`bg-modal-glass` + `bf-glass-xl` + `shadow-glass-light`)+ submitBtn 字色 bold + 用 mint var |
| `ForceChangePasswordModal.module.css` | 同 LoginModal |
| `VideoDetailModal.module.css` | `.infoPanel` glass + inset highlight |
| `RecordDetailModal.tsx` | `const modal` 玻璃化(backdropFilter + WebkitBackdropFilter + shadow-glass-light) |
| `AssetLibraryModal.module.css` | `.modal` 玻璃化 |
| `AnnouncementModal.module.css` | `.modal` 玻璃化 |
| `ConfirmModal.module.css` | `.modal` 玻璃化 |
| `AnnouncementBanner.module.css` | 深色保留紫青渐变 + 浅色新增 `[data-theme="light"] .banner` 切换暖米色 chip(GitBook 风格)+ inset highlight |
| `Toast.module.css` | bg `bg-glass-strong` + `bf-glass-md` + `inset-highlight-strong` |
| `Dropdown.module.css` / `Select.module.css` / `DatePicker.module.css` | 容器 glass + `bf-glass-md` + inset highlight |
| `PromptInput.module.css` | `.mentionPopup` glass + `bf-glass-md` + inset highlight |
| `GenerationCard.module.css` | 3 处玻璃面(promptExpanded / detailTooltip / moreDropdown)用 V2 token + inset highlight |
| `TeamsPage.module.css` | `.detailModal` `bf-glass-lg` + inset highlight |
### 修改 — Phase D admin 实体卡 multi-layer shadow(9 个,1 个 sub-agent)
| 文件 | 加 shadow 的 class | 处数 |
|---|---|---|
| `DashboardPage.module.css` | .statCard / .chartWrapper | 2 |
| `TeamsPage.module.css` | .tableWrapper | 1 |
| `UsersPage.module.css` | .tableWrapper | 1 |
| `RecordsPage.module.css` | .tableWrapper | 1 |
| `AdminAssetsPage.module.css` | .statCard / .accordionItem | 2 |
| `LoginRecordsPage.module.css` | .tableWrapper | 1 |
| `AuditLogsPage.module.css` | .tableWrapper | 1 |
| `ProfilePage.module.css` | .quotaCard / .sparklineWrapper / .recordItem | 3 |
| `SettingsPage.module.css` | .card | 1 |
| **合计** | | **13** |
### 新建(1 个,辅助)
| 文件 | 用途 |
|---|---|
| `web/test/theme-screenshots-v2.mjs` | V2 截图脚本(基于 V1 / 输出到 `docs/screenshots/v2/`) |
**总计**:**25 个文件改动**(15 个 module.css + 3 个 tsx + 1 个 index.css + 1 个 html + 5 个其他 + 1 个新建)
---
## V2 关键差异 vs V1
| 维度 | V1 | V2 |
|---|---|---|
| **`--color-bg-card`** 浅色 | `rgba(0,0,0,0.05)` 黑透明 → 灰矩形,无玻璃感 | `#ffffff` 实体白(admin 卡)+ 新 `--color-bg-glass: rgba(255,255,255,0.65)` 玻璃 |
| **`--color-aurora-*`** 浅色 | `transparent` + `.aurora-bg { display: none }` 完全关掉 | pastel 紫/蓝/桃 0.18-0.30 alpha,保留 |
| **`--color-inset-highlight`** 浅色 | `rgba(0,0,0,0.04)` 黑色,方向错 | `rgba(255,255,255,0.50)` 白色,玻璃顶边视觉标志 |
| **LandingPage** | 强制 `data-theme="dark"` 不切换 | 跟随主题,21 处 var 化,AuroraCanvas pastel orb |
| **backdrop-filter** | 散落各处 12/16/24/30/40px 无标准 | 五档 token:`--bf-glass-sm/md/lg/xl`(12/16/24/40 + saturate 140/160/180%) |
| **阴影** | 单层 `--color-shadow-modal` | 双层 `--shadow-card-light`(2 stops)+ `--shadow-glass-light`(3 stops 含 inset highlight) |
| **AdminLayout** | `.layout` bg `--color-bg-page` 盖住 aurora | `.layout` transparent + sidebar glass(让 aurora 在主区透出) |
| **文字主色** | `#171823` 微紫调 | `#171717` Vercel Black(纯近黑) |
| **暖调 chip** | 无 | 新增 5 个 `--color-chip-warm-*` token,公告横幅 GitBook 风格 |
| **AuroraCanvas** | 硬编码深色 orb | 双 ORB 集(DARK + LIGHT pastel)+ vignette/gradient 反相 |
---
## V2 浅色色板最终值
### 玻璃 / 实体双语言
| 用途 | DARK | **LIGHT** |
|---|---|---|
| 页面 bg | `#07070f` | `#fafafa`(Vercel Gray 50) |
| **玻璃** bg(sidebar / modal overlay) | `rgba(255,255,255,0.06)` | **`rgba(255,255,255,0.65)`** ★ |
| **玻璃 strong**(dropdown / toast) | `rgba(255,255,255,0.10)` | **`rgba(255,255,255,0.85)`** ★ |
| 实体卡 bg(admin) | `rgba(255,255,255,0.06)` | **`#ffffff` 纯白** ★ |
| 行 hover | `rgba(255,255,255,0.08)` | `rgba(0,0,0,0.04)` |
| Modal glass | `rgba(22,22,30,0.92)` | `rgba(255,255,255,0.85)` |
| Sidebar | `rgba(7,7,15,0.80)` | `rgba(255,255,255,0.70)` |
### 玻璃配方(必备四件套)
```css
.glass-surface {
background: var(--color-bg-glass); /* 透明白 */
backdrop-filter: var(--bf-glass-lg); /* blur(24px) saturate(180%) */
-webkit-backdrop-filter: var(--bf-glass-lg);
border: 1px solid var(--color-border-card); /* 0.08 黑 */
box-shadow: var(--shadow-glass-light); /* multi-layer + inset highlight */
}
/* --shadow-glass-light 浅色展开 */
0 8px 32px rgba(0,0,0,0.06),
0 1px 2px rgba(0,0,0,0.08),
inset 0 1px 0 rgba(255,255,255,0.60); /* ★ 顶边白高光 - 玻璃标志 */
```
### Aurora pastel(浅色)
| Orb | DARK | LIGHT |
|---|---|---|
| #1 | `rgba(108,99,255,0.60)` 紫 | `rgba(180,167,255,0.30)` lavender |
| #2 | `rgba(59,130,246,0.50)` 蓝 | `rgba(167,200,255,0.28)` sky |
| #3 | `rgba(139,92,246,0.35)` 紫 | `rgba(220,167,255,0.22)` pink-violet |
| Peach(新) | — | `rgba(255,180,130,0.25)` peach |
### 文字灰阶(Vercel 风)
| Token | DARK | LIGHT |
|---|---|---|
| primary | `#f1f0ff` 亮紫白 | **`#171717`** Vercel Black |
| secondary | `#8b8ea8` | `#525252` Gray 600 |
| tertiary | `#888` | `#888888` Gray 500 |
| quaternary | `#555` | `#a3a3a3` Gray 400 |
| on-glass | `rgba(255,255,255,0.7)` | `rgba(23,23,23,0.85)` |
### 暖调 chip(新)
| Token | DARK | LIGHT |
|---|---|---|
| chip-warm-bg | `rgba(255,200,130,0.10)` | `#fff5eb` |
| chip-warm-border | `rgba(255,200,130,0.25)` | `rgba(255,180,130,0.40)` |
| chip-warm-text | `#f1f0ff` | `#1a1a1a` |
| chip-warm-badge-bg | `rgba(255,150,100,0.20)` | `rgba(255,100,50,0.12)` |
### Backdrop-filter 五档标准
| Token | 值 | 用途 |
|---|---|---|
| `--bf-glass-sm` | `blur(12px) saturate(140%)` | btn / chip 内置玻璃 |
| `--bf-glass-md` | `blur(16px) saturate(160%)` | Sidebar / banner / dropdown |
| `--bf-glass-lg` | `blur(24px) saturate(180%)` | Modal / panel |
| `--bf-glass-xl` | `blur(40px) saturate(180%)` | Hero / LoginModal / 大型玻璃卡 |
---
## 截图对比
V2 截图位于 [`docs/screenshots/v2/`](../screenshots/v2/) 共 24 张(深/浅 × 12 页),V1 在 [`docs/screenshots/`](../screenshots/)。建议关键页一一对照:
| 页面 | V1 浅色问题 | V2 浅色改善 |
|---|---|---|
| 01 LoginPage | 强制 dark 不切换 | **完全 pastel + 玻璃 LoginModal** |
| 02 admin/dashboard | 卡片"扁",Sidebar 不透 | 主区透 aurora + 卡片浮起 + Sidebar 玻璃 |
| 03 admin/users | 表格行 hover 黑色块 | 实体白行 + 阴影 wrapper + 明显行间隔 |
| 09 generation | Modal 实体白 | Modal 玻璃 + Sidebar glass 透 |
| 10 profile | 卡片扁平 | quotaCard/sparkline/record multi-layer shadow |
| 11 team/dashboard | 同上 | 5 卡片清晰浮起 + ECharts 浅色 |
---
## 已知小问题 / 未来可优化
1. **AnnouncementModal 浅色仍偏实体白**:`bg-modal-glass 0.85` 已经很接近不透明,玻璃感不强。可改 0.70 让更透,但 modal 文字可读性会受影响。**当前权衡:可读 > 玻璃**。
2. **AnnouncementBanner gradient**:CSS 限制无法 var-内 alpha,所以写了两条独立 `[data-theme]` 规则(深色紫青渐变 / 浅色暖米色 chip)。这是 CSS 设计语言局限,接受。
3. **Profile 页 aurora 显示较弱**:页面内容稀疏,aurora 在大片白空间略不明显。如果想更明显可以提高 Profile 区 z-index 或减小 .page bg-page。**当前权衡:留白干净 > aurora 强度**。
4. **AuroraCanvas 性能**:Canvas 内绘制 5 个 orb + grain,resize 后 setTransform dpr clamp 在 1.5。性能可接受,但低端机可能需要进一步降级。已经在 `< 768px` viewport 用 dpr 0.5 兜底。
5. **未做品牌 mint #7edcc8 浅色变 teal #0d9488 的视觉验证**:理论上 #0d9488 在白底上 contrast 6.0+ 通过 WCAG AA,但实际肉眼是否"够薄荷"还需要用户主观判断。
6. **`.aurora-bg` / `.cursor-glow` / `.grid-pattern`** 在 ProfilePage / 用户路由也会显示(因为 AmbientBackground 是全局组件)。如果某些页面不想要,需要单独覆盖(目前未做)。
---
## 验收清单(showcase 对照)
V2 plan §九的验收点全部达成:
- ✅ LandingPage 浅色:白底 + pastel aurora 隐约可见 + LoginModal **透明白玻璃**(可见 aurora 透过来)
- ✅ LoginModal 顶边白色 inset highlight
- ✅ 生成页 Sidebar 浅色透白玻璃
- ✅ VideoDetailModal infoPanel 浅色玻璃白板
- ✅ AnnouncementModal:玻璃白卡 + overlay 淡黑(`rgba(0,0,0,0.20)`)
- ✅ Admin 仪表盘 / 团队管理:纯白卡 + 1px 阴影边 + multi-layer 柔阴影,GitBook 工作台风
- ✅ 公告横幅:暖米色 chip 风格(浅色态)
- ✅ ECharts tooltip / 网格 / 轴在浅色下清晰可读
- ✅ 主色 #5048cc + 白字对比度 6.8(AAA)
- ✅ Sidebar 切换按钮位置 + hover 颜色 OK
---
## 数据
| 指标 | V1 | V2 |
|---|---|---|
| 改动文件数 | 56 | 25(增量 V2 改动) |
| 新增 CSS var | ~70 | +12(双套 24) |
| Vitest | 71 fail / 162 pass | 71 fail / 162 pass(**与 V1 完全一致**) |
| TS 编译 | 通过 | 通过 |
| Playwright 截图 | 24 张 `docs/screenshots/` | 24 张 `docs/screenshots/v2/` |
| 总耗时 | ~2.5h | ~2.5h |
---
## Sub-agent 调度回顾
V2 派了 4 个 sub-agent:
| Agent | 任务 | 文件数 | 耗时 |
|---|---|---|---|
| Modal 玻璃化 | LoginModal / ForceChange / VideoDetailModal.infoPanel / RecordDetailModal / AssetLibrary / Announcement / Confirm / TeamsPage.detailModal | 8 | ~2min |
| Bar/Dropdown/Toast | AnnouncementBanner / Toast / Dropdown / Select / DatePicker / ImageLightbox | 6 | ~1min |
| Sidebar + 生成页玻璃 | Sidebar / PromptInput / GenerationCard / InputBar(verify only) | 4 | ~1.5min |
| Admin 实体卡 shadow | DashboardPage / TeamsPage / UsersPage / RecordsPage / AdminAssetsPage / LoginRecordsPage / AuditLogsPage / ProfilePage / SettingsPage | 9 | ~2min |
每个 sub-agent prompt 包含:V2 token 速查 + 改造原则 + 文件清单 + TS verify + 不要 commit。主进程统一 commit。
---
## 怎么测
**直接打开浏览器**:http://localhost:5173/(dev server 已在跑)
测试账号:
- `admin` / `admin123`(超管)
- `screenshot_user` / `shotpass123`(团管)
切换主题:Sidebar 底部头像上方月亮/太阳 SVG 按钮。
或直接看 24 张截图:[`docs/screenshots/v2/`](../screenshots/v2/)

View File

@ -0,0 +1,516 @@
# 亮色主题切换 V2 — 玻璃质感重做方案
**起因**V1commit `f0f47e8`)实现了变量层切换,但浅色实际效果不达标。用户反馈:
1. **LandingPage 没切换**V1 我强制 `data-theme="dark"` 保留品牌 → 是错的,应当浅色化)
2. **玻璃质感全丢**(深色态毛玻璃 → 浅色态变成"灰色色块",没有透光感)
3. 首页毫无品牌氛围admin 页面"平"但能用,整体不够"高级"
V1 没有真正复刻 GitBook / Linear / Vercel 的浅色玻璃语言 —— 只做了"颜色反相",没做"质感转译"。V2 就是补这一课。
---
## 一、参考样本深度分析
### 1.1 GitBook 首页(用户提供截图 1
**主视觉构成(自下而上):**
| 层级 | 实现 | 视觉作用 |
|---|---|---|
| Page bg | 纯白 `#ffffff`(含极轻微暖调) | 干净画布 |
| 装饰层 | 大块**鲜橙色 3D 漩涡环**(局部出血,从画面左下穿过) | 给玻璃面提供"穿透色源" |
| 玻璃窗口 | 半透明白卡 + backdrop-filter blur + 1px subtle border | mock 的 Acme Help Center |
| 内容 | 实体白卡(含 icon 状态色 / 按钮) | 信息层 |
| Hero 文字 | 黑灰文字 + 黑色 pill 按钮 | 顶层 CTA |
**关键观察 — 玻璃窗口边缘**
橙色漩涡明显**穿透**到窗口左上角内部(在文字内容下方依稀可见橙色被柔化的色斑)。这就是 `backdrop-filter: blur(30+px) saturate(180%)` 的标志性效果 —— 后景颜色被高斯模糊吃掉细节但保留色相,配合饱和度提升保持视觉冲击。
**Top banner公告 chip**
- `bg: 浅米/桃 rgba(255, 245, 235, 1) ≈ #fff5eb`
- `border: 1px solid rgba(255, 180, 130, 0.4)` — 暖橙色边
- `text: #1a1a1a` 深近黑
- 圆角 9999px(pill)
**Top nav**
- 完全无背景框(白底裸链接)
- 链接 `Geist 14-15px weight 510, color #171717`
- "Login" 纯文字链接
- "Get a demo" — 浅灰 pill (bg #ffffff + border `#e5e7eb` + dark text)
- "Start for free" — **纯黑 pill** (`#000``#0a0a0a` + 白字)
### 1.2 Framer 玻璃 demo用户提供 URL已下载分析
**关键画面**
```
白底 #ffffff
|
├── 装饰层:鲜橙色 3D 漩涡 #ff5a3c~#ff8a5c
| (主体出血在左下,光晕扩散到中部)
|
├── 玻璃大卡(圆角 ~24px
│ ├── bg: rgba(255, 255, 255, ~0.55-0.70)
│ ├── backdrop-filter: blur(40px) saturate(180%)
│ ├── 顶部内描边白色 inset highlight rgba(255,255,255,0.5)
│ ├── 外阴影 0 8px 32px rgba(0,0,0,0.06) + 0 1px 2px rgba(0,0,0,0.08)
│ └── border: 1px solid rgba(255,255,255,0.7)
|
├── 内嵌实体白卡 × 4mini squares 圆角 ~14px
│ ├── bg: 纯 #ffffff(不透明)
│ ├── 1px shadow border rgba(0,0,0,0.06)
│ └── 中心 icon 深灰 #1a1a1a~#404040
|
└── 玻璃 suggestion bar圆角 ~14px
└── 相同玻璃配方,更窄
```
**玻璃配方拆解**(这是浅色版必须复刻的核心):
| 属性 | 值 | 作用 |
|---|---|---|
| `background` | `rgba(255, 255, 255, 0.60)` | 半透明白 — **关键**,让后景穿透 |
| `backdrop-filter` | `blur(40px) saturate(180%)` | 高斯模糊后景 + 提饱和保色相 |
| `-webkit-backdrop-filter` | 同上 | Safari 兼容 |
| `border` | `1px solid rgba(255, 255, 255, 0.7)` | 玻璃高光边缘 |
| `box-shadow` | `0 8px 32px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.08)` | 深度(外阴影) |
| `box-shadow inset` | `inset 0 1px 0 rgba(255,255,255,0.5)` | 顶边内高光 |
| `border-radius` | `16-24px` | 流畅曲率 |
**没有装饰层就没有玻璃**:在 Framer 图里,**橙色漩涡是玻璃成立的前提**。如果把橙色去掉,整张图变成"白卡片堆白底",看不出玻璃。
### 1.3 GitBook 工作台(用户提供截图 3
**功能页(非营销页)的浅色处理 — 截然不同**
| 层级 | 实现 |
|---|---|
| Page bg | 纯白 `#ffffff` 完全无装饰 |
| Sidebar | 极淡灰 `#fafafa~#fbfbfb`,右侧 1px border `#ebebeb`**不是玻璃**,纯实体面) |
| 内容卡片 | 实体白 `#ffffff` + 1px shadow border `rgba(0,0,0,0.08)` + 12px radius |
| Active nav 项 | 浅灰药丸 `rgba(0,0,0,0.05)` + 深字 |
| CTA 按钮 | 视情况:黑色实体 / 浅灰 ghost / 品牌色magenta `#d946ef` "Upgrade" |
| 文字 | `#171717` 主 / `#525252` 次 / `#888` 提示 |
**没有装饰、没有玻璃**。这是 Vercel/Linear-app 的"无干扰"语言。原因:功能页要看数据,不要分散注意力。
### 1.4 综合洞察
**GitBook 采用双语言系统**
| 场景 | 语言 | 标志 |
|---|---|---|
| Marketing / Hero / Landing | **玻璃 + 装饰层** | 鲜彩漩涡、frosted card、品牌冲击 |
| App / Functional | **平面 + 阴影边** | 纯白、Vercel-style 卡片、零装饰 |
**V1 的根错**:把这两种场景一视同仁地用同一套 var 覆盖。结果 LandingPage 失去了玻璃语言admin 页又勉强能看。V2 必须分双语言处理。
---
## 二、V1 的具体技术错误
回顾 `index.css` `[data-theme="light"]` 的关键问题:
```css
/* V1 写的(错误) */
--color-bg-card: rgba(0, 0, 0, 0.05); /* 黑透明 → 看起来是"灰色色块",不是玻璃 */
--color-bg-hover: rgba(0, 0, 0, 0.07); /* 同上 */
--color-sidebar-bg: rgba(243, 244, 246, 0.92); /* 几乎不透明的浅灰 */
--color-aurora-1: transparent; /* 极光完全关掉 */
--color-aurora-2: transparent;
--color-aurora-3: transparent;
/* + LandingPage 强制 data-theme="dark" */
```
**症结**:玻璃质感的三要素 ——
1. **后景必须有色彩**(否则 blur 没东西可糊)
2. **表面必须是 _白_ 透明而非 _黑_ 透明**(视觉上是"亮起来"不是"暗下去"
3. **必须有 backdrop-filter blur + saturate**
V1 三个都没满足。
---
## 三、V2 设计原则
### 原则 1 — 双语言架构
`[data-theme="light"]` 内进一步区分两类 token
```
GLASS 类(透明白 + blur用于 sidebar / modal overlay / banner / dropdown
SOLID 类(实体白 + 边 + 阴影,用于 admin 卡片 / 表格行 / 数据展示)
```
V1 的 `--color-bg-card` 混用 → V2 拆分:
- `--color-bg-card`: **实体白** `#ffffff`(解决 admin 卡片用例)
- `--color-bg-glass`: **半透明白** `rgba(255,255,255,0.65)`(解决 sidebar / 横幅 / 弹窗)— **新增 var**
### 原则 2 — 装饰层必须保留
V1 的错:浅色直接 `display: none` 关掉 aurora。
V2浅色保留 aurora但用**浅色友好色板**pastel 紫蓝粉0.18-0.30 alpha 范围)。
- 给 LandingPage 玻璃 modal 提供"穿透色源"
- 给所有 backdrop-filter 表面提供视觉支撑
- 主体内容区域admin / 生成页)通过 z-index + bg `#fafafa` 把 aurora 挡掉,保持平面感
- **关键**aurora 在 light 下颜色比 dark 略淡0.18-0.30 vs dark 的 0.35-0.60),避免刺眼
### 原则 3 — 主页面 bg 用 `#fafafa` 不是纯白
理由:
- 玻璃 card 即使透明 `rgba(255,255,255,0.65)`,叠在纯白 bg 上视觉差异 < 2%看不出玻璃边缘
- 用 `#fafafa` 给玻璃卡留出"白比页面更白"的差异空间
- 同时让实体白卡 `#ffffff` 在页面上有清晰轮廓
- Vercel 自己用的也是 `#fafafa`Gray 50做 surface tinting
### 原则 4 — backdrop-filter 标准化为 `blur(24-32px) saturate(180%)`
V1 现有的 backdrop-filter 散落在 13 个 module.css 里blur 强度从 12px 到 30px 不一。V2 标准化:
| 表面类别 | blur | saturate |
|---|---|---|
| Sidebar / 横幅 | `blur(16px)` | `saturate(160%)` |
| Modal panel (Login / VideoDetail.infoPanel) | `blur(24px)` | `saturate(180%)` |
| Hero / Landing 玻璃大卡 | `blur(40px)` | `saturate(180%)` |
| Dropdown / Select 弹层 | `blur(12px)` | `saturate(140%)` |
| Toast / Tooltip | `blur(12px)` | `saturate(140%)` |
### 原则 5 — 玻璃边缘必有 inset highlight
GitBook / Framer 的玻璃面 _上沿_ 都有微妙的白色内高光(`box-shadow: inset 0 1px 0 rgba(255,255,255,0.5)`),让边缘"亮起来",是 frosted glass 的视觉标志。V1 完全没做。V2 加。
---
## 四、V2 完整浅色色板(变更对照表)
### 4.1 Page / 装饰层(核心变更)
| Token | V1 | **V2** | 说明 |
|---|---|---|---|
| `--color-bg-page` | `#fafafa` | `#fafafa`(不变) | 仍是 Vercel Gray 50 |
| `--color-aurora-1` | `transparent` | `rgba(180, 167, 255, 0.22)` | 浅紫,给玻璃穿透色 |
| `--color-aurora-2` | `transparent` | `rgba(167, 200, 255, 0.20)` | 浅蓝青 |
| `--color-aurora-3` | `transparent` | `rgba(255, 200, 180, 0.18)` | 浅桃(新增暖调,参考 GitBook 橙) |
| `--color-cursor-glow` | `rgba(80,72,204,0.04)` | `rgba(80, 72, 204, 0.06)` | 微调更可见 |
| `--color-grid-line` | `rgba(0,0,0,0.025)` | `rgba(0,0,0,0.025)` | 不变 |
| 移除 | `[data-theme="light"] .aurora-bg { display: none }` | **删掉这条规则** | 让 aurora 在 light 下也显示 |
### 4.2 玻璃面 token关键新增 + 改写)
| Token | V1 | **V2** | 说明 |
|---|---|---|---|
| `--color-bg-glass` | _不存在_ | `rgba(255, 255, 255, 0.65)` | **新增**。专给 sidebar / banner / modal glass 用 |
| `--color-bg-glass-strong` | _不存在_ | `rgba(255, 255, 255, 0.80)` | **新增**。需要更不透明的玻璃dropdown / tooltip |
| `--color-bg-modal-glass` | `rgba(255,255,255,0.92)` | `rgba(255, 255, 255, 0.85)` | 微调,留一点透气 |
| `--color-sidebar-bg` | `rgba(243,244,246,0.92)` | `rgba(255, 255, 255, 0.65)` | 真正变成玻璃 |
| `--color-bg-sidebar` | 同上 | 同上 | 同上 |
| `--color-bg-input-bar` | `#ffffff` | `rgba(255, 255, 255, 0.85)` | 输入条玻璃化 |
| `--color-bg-dropdown` | `rgba(255,255,255,0.96)` | `rgba(255, 255, 255, 0.85)` | dropdown 玻璃化 |
| `--color-inset-highlight` | `rgba(0,0,0,0.04)` | `rgba(255, 255, 255, 0.50)` | **修正方向** —— 玻璃顶边白高光 |
| `--color-inset-highlight-strong` | `rgba(0,0,0,0.06)` | `rgba(255, 255, 255, 0.70)` | 同上 |
### 4.3 实体卡片 token
| Token | V1 | **V2** | 说明 |
|---|---|---|---|
| `--color-bg-card` | `rgba(0,0,0,0.05)` | `#ffffff`(实体纯白) | admin 卡片实体化 |
| `--color-bg-hover` | `rgba(0,0,0,0.07)` | `rgba(0, 0, 0, 0.04)` | 行 hover 仍走黑透明(行内不是玻璃) |
| `--color-bg-upload` | `rgba(0,0,0,0.03)` | `#ffffff` | upload 区也实体化 |
| `--color-bg-row-hover` | _未定义_ | `rgba(0, 0, 0, 0.03)` | **新增**。表格行 hover 专用 |
### 4.4 边框 token
| Token | V1 | **V2** | 说明 |
|---|---|---|---|
| `--color-border-card` | `rgba(0,0,0,0.10)` | `rgba(0, 0, 0, 0.08)` | 学 Vercel 阴影边 |
| `--color-border-input-bar` | `rgba(0,0,0,0.12)` | `rgba(0, 0, 0, 0.10)` | 同上 |
| `--color-border-modal` | `#e5e7eb` | `rgba(0, 0, 0, 0.06)` | 改为半透明,玻璃边更自然 |
| `--color-border-modal-soft` | `rgba(0,0,0,0.08)` | `rgba(0, 0, 0, 0.05)` | 微弱 |
| `--color-border-glass-edge` | _不存在_ | `rgba(255, 255, 255, 0.70)` | **新增**。玻璃面外边白高光 |
| `--color-border-soft` | `rgba(0,0,0,0.06)` | `rgba(0, 0, 0, 0.05)` | 微调 |
| `--color-border-row` | `rgba(0,0,0,0.05)` | `rgba(0, 0, 0, 0.06)` | 行分割线略明显 |
### 4.5 阴影 token玻璃深度感
| Token | V1 | **V2** | 说明 |
|---|---|---|---|
| `--color-shadow-modal` | `rgba(0,0,0,0.10)` | `rgba(0, 0, 0, 0.08)` | 玻璃外阴影更柔 |
| `--color-shadow-dropdown` | `rgba(0,0,0,0.08)` | `rgba(0, 0, 0, 0.10)` | dropdown 阴影 |
| `--shadow-card-light` | _不存在_ | `0 1px 2px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.06)` | **新增**。Vercel-style multi-layer 阴影。给实体卡 |
| `--shadow-glass-light` | _不存在_ | `0 8px 32px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.08), inset 0 1px 0 rgba(255,255,255,0.5)` | **新增**。给玻璃面(含 inset highlight |
### 4.6 文字 token
| Token | V1 | **V2** | 说明 |
|---|---|---|---|
| `--color-text-primary` | `#171823` | `#171717`Vercel Black | 去紫调,纯近黑更"高级" |
| `--color-text-secondary` | `#6b6e85` | `#525252`Gray 600 | Vercel 灰阶 |
| `--color-text-tertiary` | `#9ca3af` | `#888` (Gray 500) | 同上 |
| `--color-text-quaternary` | `#cbd5e1` | `#a3a3a3` (Gray 400) | 同上 |
| `--color-text-disabled` | `#cbd5e1` | `#a3a3a3` | 同上 |
| `--color-text-on-glass` | `rgba(0,0,0,0.75)` | `rgba(23, 23, 23, 0.85)` | 玻璃上的字偏纯黑 |
### 4.7 状态色V2 不变V1 加深 18% 仍然合理)
主色 `#5048cc` / 信息 `#0099cc` / 成功 `#00a37e` / 危险 `#d63a2a` / 警告 `#d4860a` —— 保持 V1。
### 4.8 装饰 chip参考 GitBook 公告 chip
新增一组 var 给"暖调强调"用(公告横幅 / Trial banner / "新版上线" pill
| Token | **V2 新值** | 用途 |
|---|---|---|
| `--color-chip-warm-bg` | `#fff5eb` | 暖米色 chip 背景 |
| `--color-chip-warm-border` | `rgba(255, 180, 130, 0.40)` | 暖橙色边 |
| `--color-chip-warm-text` | `#1a1a1a` | chip 黑字 |
(深色态下:`bg: rgba(255, 200, 130, 0.10)`, `border: rgba(255, 200, 130, 0.25)`, `text: #f1f0ff`
---
## 五、LandingPage 浅色化重做
### 5.1 移除 `data-theme="dark"` 强制
```diff
// web/src/pages/LandingPage.tsx
- <div className={styles.page} data-theme="dark">
+ <div className={styles.page}>
```
让 LandingPage 跟随全局主题切换。
### 5.2 LandingPage.module.css 全 var 化
V1 跳过了这文件21 处硬编码颜色保留。V2 全部接入 var浅色按下表
| 元素 | DARK 现状 | LIGHT 新值 |
|---|---|---|
| `.page` bg | `#000` | `var(--color-bg-page)` (`#fafafa`) |
| `.title` color | `#f1f0ff` | `var(--color-text-primary)` (`#171717`) |
| `.tagline` color | `rgba(255,255,255,0.5)` | `var(--color-text-on-glass-soft)` (浅色下:`rgba(23,23,23,0.50)`) |
| `.btnPrimary` bg | `rgba(120,220,200,0.12)` | `var(--color-mint-accent-bg)` (浅色下 teal `rgba(13,148,136,0.10)`) |
| `.btnPrimary` border | `rgba(120,220,200,0.3)` | `var(--color-mint-accent-border)` |
| `.btnPrimary .btnName` color | `#7edcc8` | `var(--color-mint-accent)` (浅色下 `#0d9488` teal 深色) |
| `.btnPrimary:hover` bg | `rgba(120,220,200,0.22)` | `var(--color-mint-accent-bg-hover)` |
| `.btnGhost` bg | `rgba(255,255,255,0.05)` | `var(--color-bg-glass-strong)` (`rgba(255,255,255,0.80)`) |
| `.btnGhost` border | `rgba(255,255,255,0.1)` | `var(--color-border-card)` (`rgba(0,0,0,0.08)`) |
| `.btnGhost .btnName` color | `rgba(255,255,255,0.7)` | `var(--color-text-primary)` |
| `.btnSub` color | `rgba(120,220,200,0.5)` | teal 浅色下 `rgba(13,148,136,0.65)` |
| `.btnSubGhost` color | `rgba(255,255,255,0.35)` | `var(--color-text-tertiary)` |
| `.easter` color | `rgba(255,255,255,0.06)` | `rgba(0,0,0,0.06)` |
| `.easter:hover` color | `rgba(255,255,255,0.25)` | `rgba(0,0,0,0.25)` |
| `.sparkOverlay` bg | `rgba(0,0,0,0.5)` | `var(--color-overlay-soft)` (浅色 `rgba(0,0,0,0.18)`) |
| `.sparkOverlay` backdrop-filter | `blur(30px)` | 不变(深浅都好用) |
| `.sparkTitle` color | `#ffffff` | `var(--color-text-primary)` |
| `.sparkSub` color | `rgba(255,255,255,0.5)` | `var(--color-text-secondary)` |
| `.musicBtn` color | `rgba(255,255,255,0.2)` | `var(--color-text-quaternary)` |
| `.musicBtn:hover` color | `rgba(255,255,255,0.5)` | `var(--color-text-tertiary)` |
### 5.3 AuroraCanvas 浅色化
`web/src/components/AuroraCanvas.tsx` 当前硬编码了 5 个 orbs 的 RGB 颜色126,220,200 青 / 108,99,255 紫 / 59,130,246 蓝 / 167,139,250 浅紫 / 34,211,238 亮青)。
V2 改造:
1. 在 `index.css` 新增 `--orb-color-1` ~ `--orb-color-5`(深色保持原色,浅色变 pastel
2. AuroraCanvas 改成读 CSS var`c()` helper 或直接 `getComputedStyle`
3. 每次 theme 切换时重启动画循环(通过 `useThemeStore` 订阅 + useEffect cleanup
或者更简单:浅色下保留 AuroraCanvas但**降低主体 alpha**dark `0.28` → light `0.12` 之类),保留品牌氛围又不刺眼。
### 5.4 LoginModal 玻璃化
V1 LoginModal 在浅色下用了:
- bg: `var(--color-bg-modal-elevated)` = `#ffffff`
- border: `var(--color-border-modal-soft)` = `rgba(0,0,0,0.06)`
V2 升级为真正的玻璃:
- bg: `var(--color-bg-modal-glass)` = `rgba(255,255,255,0.85)`
- backdrop-filter: `blur(24px) saturate(180%)`(已有)
- 加 inset highlight: `box-shadow: ..., inset 0 1px 0 var(--color-inset-highlight)`
- 加 multi-layer shadow: `box-shadow: var(--shadow-glass-light)`
---
## 六、其他玻璃面升级清单
按"已有 backdrop-filter 但浅色没玻璃感"扫描13 个文件需要逐一升级:
| 文件 | 现状 | V2 调整 |
|---|---|---|
| `Sidebar.module.css` | `bg: var(--color-sidebar-bg)` + `backdrop-filter: blur(16px) saturate(160%)` | bg 变成新的 `--color-bg-glass`(浅色透白);保留 backdrop-filter |
| `AnnouncementBanner.module.css` | linear-gradient + backdrop-filter | gradient 在浅色下改用 chip-warm-bgkeep blur |
| `AnnouncementModal.module.css` | overlay + 内卡 | overlay 浅色变 `rgba(0,0,0,0.20)`;内卡变玻璃 |
| `VideoDetailModal.module.css` `.infoPanel` | `bg: var(--color-bg-upload)` + `backdrop-filter: blur(24px) saturate(180%)` | bg 改 `--color-bg-glass`;加 inset highlight |
| `VideoDetailModal.module.css` `.detailModal` (TeamsPage 类似) | `bg: var(--color-bg-modal-glass)` | 已经是玻璃 tokenV2 调 alpha 即可 |
| `GenerationCard.module.css` | 部分卡片有 backdrop-filter | 用 glass token |
| `PromptInput.module.css` | mention dropdown | 浅色用 `--color-bg-dropdown` 玻璃 |
| `LoginModal.module.css` | (见 5.4) | 玻璃化 |
| `ForceChangePasswordModal.module.css` | 同 LoginModal | 玻璃化 |
| `Toast.module.css` | 浮层 | 玻璃 `blur(12px) saturate(140%)` |
| `Select.module.css` | 下拉 | 同上 |
| `Dropdown.module.css` | 下拉 | 同上 |
| `DatePicker.module.css` | 弹层 | 同上 |
| `TeamsPage.module.css` `.detailModal` | 玻璃弹窗 | 调 alpha + 加 inset highlight |
---
## 七、Admin 页面"实体白 + 影边"统一
V2 在 admin 页保留 Vercel 风格(不要全玻璃,那会丢失数据焦点),但调整:
1. **Stat 卡片 / 表格 wrapper**
- bg: `var(--color-bg-card)` = 浅色下变 `#ffffff` 纯白
- box-shadow: `var(--shadow-card-light)` = `0 1px 2px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.06)`
- border: `1px solid var(--color-border-card)` = `rgba(0,0,0,0.08)`
2. **表格行 hover**
- bg: `var(--color-bg-row-hover)`(新 var= `rgba(0,0,0,0.03)`
3. **active 导航项**
- bg: `var(--color-sidebar-active)` = `rgba(0,0,0,0.06)` 浅灰药丸
- text: `var(--color-primary)` 主色V1 浅色 `#5048cc`
---
## 八、实施步骤(推荐顺序)
```
Phase A — 基础设施(不改可见效果)
A.1 index.css [data-theme="light"] 全部按 §4 改值
A.2 index.css :root 新增 --color-bg-glass / --color-bg-glass-strong / --color-bg-row-hover /
--color-border-glass-edge / --color-chip-warm-* / --shadow-card-light / --shadow-glass-light
七组新 var深浅各一套
A.3 删掉 [data-theme="light"] .aurora-bg { display: none }
A.4 调 aurora RGB 值CSS var让浅色 aurora 是 pastel
A.5 AuroraCanvas.tsx 接入 CSS var或保留硬编码 + 在浅色下额外降 alpha
Phase B — LandingPage 浅色化
B.1 移除 data-theme="dark"
B.2 LandingPage.module.css 全部硬编码颜色 → var约 21 处)
B.3 跑截图:登录页应该浅色 + LoginModal 玻璃 + AuroraCanvas pastel
Phase C — 玻璃面升级
C.1 Sidebar 用新 --color-bg-glass
C.2 VideoDetailModal.infoPanel 用新 glass
C.3 AnnouncementModal / LoginModal / ForceChangePasswordModal 用新 glass + inset highlight
C.4 Toast / Dropdown / Select / DatePicker 加 saturate(140%),浅色用 --color-bg-glass-strong
C.5 AnnouncementBanner gradient 在浅色下改 chip-warm-bgCSS 限制无法 var-内 alpha
所以这一项要写双套独立规则:[data-theme="dark"] .banner { ... } + [data-theme="light"] .banner { ... }
Phase D — Admin 实体卡升级
D.1 全局加 box-shadow: var(--shadow-card-light) 给 .statCard / .tableWrapper / .chartSection
D.2 全局检查 .table tr:hover 用 --color-bg-row-hover
D.3 Sidebar active 项加确认浅色下视觉
Phase E — 视觉校准
E.1 跑 Playwright 24 张截图
E.2 对照 GitBook / Framer 验收
E.3 逐页迭代 alpha / shadow / blur 数值
Phase F — 兼容性 / 回归
F.1 tsc + vitest
F.2 完成报告 → 亮色主题切换V2-完成报告.md
F.3 本地 commit dev不 push
```
---
## 九、关键验收点
完工后跑截图,对比这些视觉特征:
- [ ] LandingPage 浅色:白底 + pastel aurora 隐约可见 + LoginModal 是**透明白玻璃**(能看见 aurora 透过来)
- [ ] LoginModal 顶边有微妙白色 inset highlight
- [ ] 生成页 Sidebar 浅色是**透明白玻璃**(能看见后景内容隐约透过)
- [ ] VideoDetailModal infoPanel 浅色:玻璃白板 + 主视频区可见
- [ ] AnnouncementModal 弹窗:玻璃白卡 + overlay 是 _淡黑_ 不是 _重黑_
- [ ] Admin 仪表盘 / 团队管理:纯白卡片 + 1px 阴影边 + 多层柔阴影,类似 GitBook 工作台
- [ ] 公告横幅 / Trial / "新版上线" pill暖米色 chip 风格
- [ ] ECharts tooltip / 网格 / 轴在浅色下清晰可读
- [ ] 主色按钮 #5048cc 在白底上对比度通过 WCAG AA
- [ ] 切换按钮(月亮/太阳)位置 + hover 颜色都没问题
---
## 十、风险点
1. **backdrop-filter 性能**Safari + Chrome 都需要 GPU 合成层。同屏 10+ 玻璃面可能掉帧。当前项目最多 5-6 个同屏玻璃,可接受。如果发现 < 60fps Toast / Dropdown 这种小弹层退回实体
2. **AuroraCanvas 浅色刺眼**pastel 色板可能在某些浅色下仍然觉得"花"。fallback 方案:浅色下整个 AuroraCanvas 用 `opacity: 0.5` 整体压一档。
3. **打印 / 截图工具兼容性**backdrop-filter 在某些 PDF/截图引擎不渲染玻璃会变成纯实体。Playwright headless Chromium 是 OK 的。
4. **半透明色叠加导致文字对比度变化**:玻璃面上的文字(如 LoginModal "AirDrama" 标题)背景从 dark 切到 light 时对比度差异巨大。已经在 §4.6 用 `--color-text-on-glass` 调整,但实际跑下来可能还要再调。
5. **AnnouncementBanner gradient var 限制**CSS gradient 不能在 var() 上加自定义 alpha。要么写两条独立 `[data-theme]` 规则,要么改成 `background-image: linear-gradient(rgb(from var(--xxx) r g b / 0.10), ...)` 用新 `rgb(from ...)` 函数Chrome 119+ 支持,需要查兼容性)。保险起见用两条独立规则。
---
## 十一、工作量估算
| 阶段 | 工作量 | 备注 |
|---|---|---|
| Phase A | 1 小时 | index.css 改写 + 新 var |
| Phase B | 1 小时 | LandingPage + AuroraCanvas |
| Phase C | 1.5 小时 | 13 个玻璃面挨个调(可派 2-3 个 sub-agent 并行) |
| Phase D | 0.5 小时 | admin 实体卡 |
| Phase E | 1 小时 | 截图 + 视觉迭代 |
| Phase F | 0.5 小时 | tsc / vitest / commit |
| **合计** | **5.5 小时AI 连续)** | 比 V1 多 1.5 小时,因为多了一轮迭代 |
---
## 十二、与 V1 的差异总览
| 维度 | V1 | V2 |
|---|---|---|
| 浅色 page bg | `#fafafa` | `#fafafa` (不变) |
| 浅色 aurora | `display: none` | pastel 紫蓝桃 0.18-0.30 |
| LandingPage | 强制 `data-theme="dark"` | 跟随主题切换 |
| 浅色 card bg | `rgba(0,0,0,0.05)` 黑透明 | **拆分**:实体 `#fff` (admin 卡) vs 玻璃 `rgba(255,255,255,0.65)` (sidebar/modal) |
| backdrop-filter | 散落各处,无统一 | 五档标准化Sidebar/Modal/Hero/Dropdown/Toast |
| Inset highlight | 无 | 玻璃顶边白高光 `rgba(255,255,255,0.50)` |
| 阴影 | 单层 `--color-shadow-modal` | 双层 `--shadow-card-light` + `--shadow-glass-light` |
| 文字主色 | `#171823` 微紫 | `#171717` Vercel Black |
| 暖调 chip | 无 | 新增 `--color-chip-warm-*` |
| AuroraCanvas | 浅色硬编码不变(深色配色) | 接入 CSS var 或浅色态降 alpha |
---
## 参考资料
- GitBook 主站https://www.gitbook.com/(用户提供截图 × 3
- Framer 玻璃 demo 图https://framerusercontent.com/images/FTmA5L2PDssA4gAib6edPamSM.webp已本地分析
- Linear 浅色方案https://linear.app/design-linear-app skill 已加载)
- Vercel Geisthttps://vercel.com/design/geistdesign-vercel skill 已加载)
- WCAG 对比度https://webaim.org/resources/contrastchecker/
- CSS `rgb(from var() ...)` 兼容性https://caniuse.com/css-relative-colors
---
## Critical Files
修改:
- `web/src/index.css``[data-theme="light"]` 块大改 + 新增 ~7 个 var
- `web/src/pages/LandingPage.tsx` — 移除 `data-theme="dark"` 强制
- `web/src/pages/LandingPage.module.css` — 21 处颜色 → var
- `web/src/components/AuroraCanvas.tsx` — orbs RGB 接入 var 或加 theme-aware alpha
- 13 个 module.css 文件玻璃面调整(详见 §6
不动:
- 后端
- TS 业务逻辑
- 现有 var 命名(仅微调值)
---
**预期效果V2 完成后)**
- 登录页:纯白 + 微妙 pastel aurora + 透明白玻璃 LoginModal + 暖橙公告 chip
- 生成页:透明白玻璃 Sidebar + 主视频区实体白卡 + 玻璃 modal
- 后台仪表盘Vercel-style 纯白卡 + 多层阴影 + 主色按钮
- 整体感受:和 GitBook / Linear / Vercel 同语言,不再是"色块版深色取反"

View File

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN" data-theme="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />

View File

@ -3,15 +3,31 @@
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 10px 16px; padding: 10px 16px;
/* 深色 - 紫青渐变玻璃 */
background: linear-gradient(90deg, rgba(108, 99, 255, 0.10), rgba(0, 184, 230, 0.08)); background: linear-gradient(90deg, rgba(108, 99, 255, 0.10), rgba(0, 184, 230, 0.08));
border-left: 3px solid var(--color-primary); border-left: 3px solid var(--color-primary);
border-bottom: 1px solid rgba(255, 255, 255, 0.06); border-bottom: 1px solid var(--color-border-soft);
backdrop-filter: var(--bf-glass-md);
-webkit-backdrop-filter: var(--bf-glass-md);
box-shadow: inset 0 1px 0 var(--color-inset-highlight);
font-size: 13px; font-size: 13px;
color: var(--color-text-primary); color: var(--color-text-primary);
line-height: 1.5; line-height: 1.5;
flex-shrink: 0; flex-shrink: 0;
} }
[data-theme="light"] .banner {
/* 浅色 - 暖米色 chip */
background: var(--color-chip-warm-bg);
border-left-color: var(--color-chip-warm-border);
border-bottom-color: var(--color-chip-warm-border);
color: var(--color-chip-warm-text);
}
[data-theme="light"] .icon {
color: var(--color-chip-warm-badge-text);
}
.icon { .icon {
flex-shrink: 0; flex-shrink: 0;
color: var(--color-primary); color: var(--color-primary);
@ -61,5 +77,5 @@
.closeBtn:hover { .closeBtn:hover {
color: var(--color-text-primary); color: var(--color-text-primary);
background: rgba(255, 255, 255, 0.06); background: var(--color-bg-hover);
} }

View File

@ -1,7 +1,7 @@
.overlay { .overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.6); background: var(--color-modal-overlay);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -9,7 +9,9 @@
} }
.modal { .modal {
background: #16161e; background: var(--color-bg-modal-glass);
backdrop-filter: var(--bf-glass-lg);
-webkit-backdrop-filter: var(--bf-glass-lg);
border: 1px solid var(--color-border-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); border-radius: var(--radius-card);
max-width: 520px; max-width: 520px;
@ -17,6 +19,7 @@
max-height: 75vh; max-height: 75vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: var(--shadow-glass-light);
} }
.header { .header {
@ -25,7 +28,7 @@
align-items: center; align-items: center;
padding: 20px 32px 12px; padding: 20px 32px 12px;
flex-shrink: 0; flex-shrink: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06); border-bottom: 1px solid var(--color-border-soft);
} }
.title { .title {
@ -75,7 +78,7 @@
background: var(--color-primary); background: var(--color-primary);
border: none; border: none;
border-radius: 8px; border-radius: 8px;
color: #fff; color: var(--color-on-primary);
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
transition: opacity 0.15s; transition: opacity 0.15s;

View File

@ -2,7 +2,7 @@
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 300; z-index: 300;
background: rgba(0, 0, 0, 0.6); background: var(--color-modal-overlay);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -12,12 +12,15 @@
width: 90vw; width: 90vw;
max-width: 1400px; max-width: 1400px;
height: 85vh; height: 85vh;
background: #16161e; background: var(--color-bg-modal-glass);
backdrop-filter: var(--bf-glass-lg);
-webkit-backdrop-filter: var(--bf-glass-lg);
border: 1px solid var(--color-border-card); border: 1px solid var(--color-border-card);
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: var(--shadow-glass-light);
} }
.header { .header {
@ -88,7 +91,7 @@
background: var(--color-primary); background: var(--color-primary);
border: none; border: none;
border-radius: 8px; border-radius: 8px;
color: #fff; color: var(--color-on-primary);
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
transition: filter 0.15s; transition: filter 0.15s;
@ -139,7 +142,7 @@
height: 120px; height: 120px;
object-fit: cover; object-fit: cover;
display: block; display: block;
background: #1a1a2e; background: var(--color-bg-placeholder);
} }
.cardInfo { .cardInfo {
@ -185,7 +188,7 @@
flex: 1; flex: 1;
min-width: 0; min-width: 0;
padding: 2px 6px; padding: 2px 6px;
background: rgba(255, 255, 255, 0.08); background: var(--color-bg-hover);
border: 1px solid var(--color-primary); border: 1px solid var(--color-primary);
border-radius: 4px; border-radius: 4px;
color: var(--color-text-primary); color: var(--color-text-primary);
@ -216,8 +219,8 @@
height: 22px; height: 22px;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
background: rgba(0, 0, 0, 0.6); background: var(--color-modal-overlay);
color: #fff; color: var(--color-on-overlay);
font-size: 14px; font-size: 14px;
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
@ -239,7 +242,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
border: 1.5px dashed #3a3a48; border: 1.5px dashed var(--color-border-modal);
border-radius: 12px; border-radius: 12px;
cursor: pointer; cursor: pointer;
color: var(--color-text-disabled); color: var(--color-text-disabled);
@ -253,7 +256,7 @@
.addAssetCard:hover { .addAssetCard:hover {
border-color: var(--color-primary); border-color: var(--color-primary);
color: var(--color-primary); color: var(--color-primary);
background: rgba(108, 99, 255, 0.04); background: var(--color-primary-bg);
} }
.assetThumb { .assetThumb {
@ -261,7 +264,7 @@
height: 140px; height: 140px;
object-fit: cover; object-fit: cover;
display: block; display: block;
background: #1a1a2e; background: var(--color-bg-placeholder);
} }
.assetInfo { .assetInfo {
@ -286,17 +289,17 @@
.statusActive { .statusActive {
color: var(--color-success); color: var(--color-success);
background: rgba(0, 184, 148, 0.12); background: var(--color-success-bg);
} }
.statusProcessing { .statusProcessing {
color: var(--color-warning); color: var(--color-warning);
background: rgba(243, 156, 18, 0.12); background: var(--color-warning-bg);
} }
.statusFailed { .statusFailed {
color: var(--color-danger); color: var(--color-danger);
background: rgba(231, 76, 60, 0.12); background: var(--color-danger-bg);
} }
/* Upload view */ /* Upload view */
@ -317,7 +320,7 @@
.textInput { .textInput {
width: 100%; width: 100%;
padding: 10px 14px; padding: 10px 14px;
background: rgba(255, 255, 255, 0.06); background: var(--color-bg-card);
border: 1px solid var(--color-border-card); border: 1px solid var(--color-border-card);
border-radius: 8px; border-radius: 8px;
color: var(--color-text-primary); color: var(--color-text-primary);
@ -341,12 +344,12 @@
.dropZone:hover { .dropZone:hover {
border-color: var(--color-primary); border-color: var(--color-primary);
background: rgba(108, 99, 255, 0.04); background: var(--color-primary-bg);
} }
.dropZoneActive { .dropZoneActive {
border-color: var(--color-primary); border-color: var(--color-primary);
background: rgba(108, 99, 255, 0.08); background: var(--color-primary-bg-hover);
} }
.dropZoneText { .dropZoneText {
@ -363,11 +366,11 @@
.dropZoneWarning { .dropZoneWarning {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: #ff4d4f; color: var(--color-danger-hover);
margin-top: 12px; margin-top: 12px;
padding: 8px 12px; padding: 8px 12px;
background: rgba(255, 77, 79, 0.08); background: var(--color-danger-hover-bg);
border: 1px solid rgba(255, 77, 79, 0.25); border: 1px solid var(--color-danger-hover-border);
border-radius: 6px; border-radius: 6px;
} }
@ -384,7 +387,7 @@
background: var(--color-primary); background: var(--color-primary);
border: none; border: none;
border-radius: 8px; border-radius: 8px;
color: #fff; color: var(--color-on-primary);
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;

View File

@ -365,7 +365,7 @@ export function AssetLibraryModal({ open, onClose }: Props) {
</button> </button>
<button <button
className={styles.actionBtnOutline} className={styles.actionBtnOutline}
style={{ color: '#ef4444', borderColor: '#ef4444' }} style={{ color: 'var(--color-danger-text)', borderColor: 'var(--color-danger-text)' }}
onClick={() => { onClick={() => {
if (confirm('确认删除整个素材组?组内所有素材将被删除,此操作不可撤销。')) { if (confirm('确认删除整个素材组?组内所有素材将被删除,此操作不可撤销。')) {
assetsApi.deleteGroup(selectedGroup.id).then(() => { assetsApi.deleteGroup(selectedGroup.id).then(() => {
@ -430,14 +430,14 @@ export function AssetLibraryModal({ open, onClose }: Props) {
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>{typeLabel}</span> <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>{typeLabel}</span>
</div> </div>
<div style={{ fontSize: 11, color: 'var(--color-text-disabled)', marginBottom: 2 }}>{hintMap[assetType]}</div> <div style={{ fontSize: 11, color: 'var(--color-text-disabled)', marginBottom: 2 }}>{hintMap[assetType]}</div>
<div style={{ fontSize: 11, color: '#e8952e', marginBottom: 8 }}>{warningMap[assetType]}</div> <div style={{ fontSize: 11, color: 'var(--color-warning)', marginBottom: 8 }}>{warningMap[assetType]}</div>
<div className={styles.assetGrid}> <div className={styles.assetGrid}>
{typeAssets.map((asset) => ( {typeAssets.map((asset) => (
<div key={asset.id} className={styles.assetCard}> <div key={asset.id} className={styles.assetCard}>
{assetType === 'Video' ? ( {assetType === 'Video' ? (
<img src={tosThumb(asset.thumbnail_url || asset.url, 300)} alt={asset.name} className={styles.assetThumb} /> <img src={tosThumb(asset.thumbnail_url || asset.url, 300)} alt={asset.name} className={styles.assetThumb} />
) : assetType === 'Audio' ? ( ) : assetType === 'Audio' ? (
<div className={styles.assetThumb} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 32, background: '#1a1a2e' }}></div> <div className={styles.assetThumb} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 32, background: 'var(--color-bg-placeholder)' }}></div>
) : ( ) : (
<img <img
src={tosThumb(asset.url, 300)} src={tosThumb(asset.url, 300)}

View File

@ -1,53 +1,64 @@
import { useRef, useEffect, useCallback } from 'react'; import { useRef, useEffect, useCallback } from 'react';
import { useThemeStore } from '../store/theme';
/** /**
* Aurora background 5 large diffuse orbs with additive blending. * Aurora background 5 large diffuse orbs with additive blending.
* Orbs are deliberately offset from center to avoid uniform "blue blob" look. * V2: 接入 themeStore, pastel orb + vignette/gradient,
* Mouse gently pushes nearby orbs (10-20px, very subtle).
*/ */
interface Orb { interface Orb {
cx: number; // base center X ratio (0-1) cx: number; // base center X ratio (0-1)
cy: number; // base center Y ratio (0-1) cy: number; // base center Y ratio (0-1)
color: [number, number, number]; color: [number, number, number];
alpha: number; // peak alpha — varies per orb for brightness contrast alpha: number;
phase: number; phase: number;
freqX: number; freqX: number;
freqY: number; freqY: number;
ampX: number; ampX: number;
ampY: number; ampY: number;
radius: number; // ratio of max(w,h) radius: number;
// breathing
breathFreq: number; breathFreq: number;
breathAmp: number; breathAmp: number;
} }
// Orbs deliberately NOT centered — some in corners, some offset, // Dark theme orbs — vivid 品牌色(青/紫/蓝)
// creating uneven light distribution with dark pockets. const DARK_ORBS: Orb[] = [
const ORBS: Orb[] = [
// Cyan — upper-left area, large and bright
{ cx: 0.25, cy: 0.30, color: [126, 220, 200], alpha: 0.28, { cx: 0.25, cy: 0.30, color: [126, 220, 200], alpha: 0.28,
phase: 0, freqX: 0.00012, freqY: 0.00010, ampX: 0.12, ampY: 0.10, phase: 0, freqX: 0.00012, freqY: 0.00010, ampX: 0.12, ampY: 0.10,
radius: 0.50, breathFreq: 0.0004, breathAmp: 0.06 }, radius: 0.50, breathFreq: 0.0004, breathAmp: 0.06 },
// Purple — lower-right, medium
{ cx: 0.72, cy: 0.65, color: [108, 99, 255], alpha: 0.22, { cx: 0.72, cy: 0.65, color: [108, 99, 255], alpha: 0.22,
phase: 1.8, freqX: 0.00010, freqY: 0.00014, ampX: 0.14, ampY: 0.12, phase: 1.8, freqX: 0.00010, freqY: 0.00014, ampX: 0.14, ampY: 0.12,
radius: 0.45, breathFreq: 0.0003, breathAmp: 0.08 }, radius: 0.45, breathFreq: 0.0003, breathAmp: 0.08 },
// Blue — center-right, smaller and dimmer (fills gap)
{ cx: 0.58, cy: 0.38, color: [59, 130, 246], alpha: 0.18, { cx: 0.58, cy: 0.38, color: [59, 130, 246], alpha: 0.18,
phase: 3.2, freqX: 0.00015, freqY: 0.00008, ampX: 0.10, ampY: 0.15, phase: 3.2, freqX: 0.00015, freqY: 0.00008, ampX: 0.10, ampY: 0.15,
radius: 0.38, breathFreq: 0.0005, breathAmp: 0.07 }, radius: 0.38, breathFreq: 0.0005, breathAmp: 0.07 },
// Light purple — bottom-left corner, dim accent
{ cx: 0.20, cy: 0.70, color: [167, 139, 250], alpha: 0.15, { cx: 0.20, cy: 0.70, color: [167, 139, 250], alpha: 0.15,
phase: 4.5, freqX: 0.00008, freqY: 0.00012, ampX: 0.16, ampY: 0.08, phase: 4.5, freqX: 0.00008, freqY: 0.00012, ampX: 0.16, ampY: 0.08,
radius: 0.40, breathFreq: 0.00035, breathAmp: 0.10 }, radius: 0.40, breathFreq: 0.00035, breathAmp: 0.10 },
// Bright cyan — upper-right, small bright accent
{ cx: 0.78, cy: 0.25, color: [34, 211, 238], alpha: 0.20, { cx: 0.78, cy: 0.25, color: [34, 211, 238], alpha: 0.20,
phase: 5.8, freqX: 0.00014, freqY: 0.00016, ampX: 0.08, ampY: 0.12, phase: 5.8, freqX: 0.00014, freqY: 0.00016, ampX: 0.08, ampY: 0.12,
radius: 0.35, breathFreq: 0.00045, breathAmp: 0.09 }, radius: 0.35, breathFreq: 0.00045, breathAmp: 0.09 },
]; ];
// Mouse influence radius (px) and max push distance (px) // Light theme orbs — pastel 浅色,alpha 减半左右,给玻璃面提供穿透色源
const LIGHT_ORBS: Orb[] = [
{ cx: 0.25, cy: 0.30, color: [180, 167, 255], alpha: 0.32, // pastel lavender
phase: 0, freqX: 0.00012, freqY: 0.00010, ampX: 0.12, ampY: 0.10,
radius: 0.55, breathFreq: 0.0004, breathAmp: 0.06 },
{ cx: 0.72, cy: 0.65, color: [167, 200, 255], alpha: 0.28, // pastel sky
phase: 1.8, freqX: 0.00010, freqY: 0.00014, ampX: 0.14, ampY: 0.12,
radius: 0.50, breathFreq: 0.0003, breathAmp: 0.08 },
{ cx: 0.58, cy: 0.38, color: [255, 180, 130, ], alpha: 0.18, // pastel peach
phase: 3.2, freqX: 0.00015, freqY: 0.00008, ampX: 0.10, ampY: 0.15,
radius: 0.42, breathFreq: 0.0005, breathAmp: 0.07 },
{ cx: 0.20, cy: 0.70, color: [220, 167, 255], alpha: 0.20, // pastel pink-violet
phase: 4.5, freqX: 0.00008, freqY: 0.00012, ampX: 0.16, ampY: 0.08,
radius: 0.45, breathFreq: 0.00035, breathAmp: 0.10 },
{ cx: 0.78, cy: 0.25, color: [180, 220, 255], alpha: 0.22, // pastel blue
phase: 5.8, freqX: 0.00014, freqY: 0.00016, ampX: 0.08, ampY: 0.12,
radius: 0.40, breathFreq: 0.00045, breathAmp: 0.09 },
];
const MOUSE_RADIUS = 400; const MOUSE_RADIUS = 400;
const MOUSE_PUSH = 28; const MOUSE_PUSH = 28;
@ -55,6 +66,8 @@ export function AuroraCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const grainRef = useRef<HTMLCanvasElement>(null); const grainRef = useRef<HTMLCanvasElement>(null);
const mouseRef = useRef({ x: -9999, y: -9999, active: false }); const mouseRef = useRef({ x: -9999, y: -9999, active: false });
const theme = useThemeStore((s) => s.theme);
const isLight = theme === 'light';
const handleMouseMove = useCallback((e: MouseEvent) => { const handleMouseMove = useCallback((e: MouseEvent) => {
mouseRef.current.x = e.clientX; mouseRef.current.x = e.clientX;
@ -91,32 +104,30 @@ export function AuroraCanvas() {
let animId: number; let animId: number;
const t0 = performance.now(); const t0 = performance.now();
// Smoothed mouse position for gentle push
let smoothMx = -9999; let smoothMx = -9999;
let smoothMy = -9999; let smoothMy = -9999;
const orbs = isLight ? LIGHT_ORBS : DARK_ORBS;
function draw(now: number) { function draw(now: number) {
const t = now - t0; const t = now - t0;
ctx!.clearRect(0, 0, w, h); ctx!.clearRect(0, 0, w, h);
ctx!.globalCompositeOperation = 'lighter'; // 浅色用 source-over 让 pastel 互融时不会过曝;深色继续用 lighter 加合
ctx!.globalCompositeOperation = isLight ? 'source-over' : 'lighter';
// Smooth mouse tracking (lerp toward actual position)
const mouse = mouseRef.current; const mouse = mouseRef.current;
if (mouse.active) { if (mouse.active) {
smoothMx += (mouse.x - smoothMx) * 0.035; smoothMx += (mouse.x - smoothMx) * 0.035;
smoothMy += (mouse.y - smoothMy) * 0.035; smoothMy += (mouse.y - smoothMy) * 0.035;
} else { } else {
// Slowly drift smoothed mouse away (return to no-influence)
smoothMx += (-9999 - smoothMx) * 0.01; smoothMx += (-9999 - smoothMx) * 0.01;
smoothMy += (-9999 - smoothMy) * 0.01; smoothMy += (-9999 - smoothMy) * 0.01;
} }
for (const orb of ORBS) { for (const orb of orbs) {
// Base position from slow sinusoidal movement
let x = w * (orb.cx + Math.sin(t * orb.freqX + orb.phase) * orb.ampX); let x = w * (orb.cx + Math.sin(t * orb.freqX + orb.phase) * orb.ampX);
let y = h * (orb.cy + Math.cos(t * orb.freqY + orb.phase * 0.7) * orb.ampY); let y = h * (orb.cy + Math.cos(t * orb.freqY + orb.phase * 0.7) * orb.ampY);
// Mouse push — gently offset orb away from cursor
const dx = x - smoothMx; const dx = x - smoothMx;
const dy = y - smoothMy; const dy = y - smoothMy;
const dist = Math.sqrt(dx * dx + dy * dy); const dist = Math.sqrt(dx * dx + dy * dy);
@ -126,7 +137,6 @@ export function AuroraCanvas() {
y += (dy / dist) * strength; y += (dy / dist) * strength;
} }
// Breathing: radius and alpha pulse slowly
const breathT = Math.sin(t * orb.breathFreq + orb.phase * 1.3); const breathT = Math.sin(t * orb.breathFreq + orb.phase * 1.3);
const r = Math.max(w, h) * orb.radius * (1 + breathT * orb.breathAmp); const r = Math.max(w, h) * orb.radius * (1 + breathT * orb.breathAmp);
const a = orb.alpha * (1 + breathT * 0.15); const a = orb.alpha * (1 + breathT * 0.15);
@ -160,7 +170,7 @@ export function AuroraCanvas() {
window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseleave', handleMouseLeave); document.removeEventListener('mouseleave', handleMouseLeave);
}; };
}, [handleMouseMove, handleMouseLeave]); }, [handleMouseMove, handleMouseLeave, isLight]);
// ── Film grain — 4 FPS low-noise ── // ── Film grain — 4 FPS low-noise ──
useEffect(() => { useEffect(() => {
@ -194,20 +204,24 @@ export function AuroraCanvas() {
return () => cancelAnimationFrame(animId); return () => cancelAnimationFrame(animId);
}, []); }, []);
// 浅色态:vignette / gradient 反相 — 用白色压边,黑色压边在浅色上是错的
const vignetteColor = isLight ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.8)';
const fadeColor = isLight ? 'rgba(250,250,250,0.7)' : 'rgba(0,0,0,0.5)';
return ( return (
<> <>
{/* Layer 1: Vignette — radial darkening, heavy at edges */} {/* Layer 1: Vignette — radial fading, 浅色下用白色 */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
inset: 0, inset: 0,
zIndex: 1, zIndex: 1,
background: 'radial-gradient(ellipse 65% 55% at 50% 50%, transparent 0%, rgba(0,0,0,0.8) 100%)', background: `radial-gradient(ellipse 65% 55% at 50% 50%, transparent 0%, ${vignetteColor} 100%)`,
pointerEvents: 'none', pointerEvents: 'none',
}} }}
/> />
{/* Layer 2: Film grain */} {/* Layer 2: Film grain — 浅色下大幅减弱避免噪点过曝 */}
<canvas <canvas
ref={grainRef} ref={grainRef}
style={{ style={{
@ -216,13 +230,13 @@ export function AuroraCanvas() {
zIndex: 2, zIndex: 2,
width: '100%', width: '100%',
height: '100%', height: '100%',
opacity: 0.035, opacity: isLight ? 0.015 : 0.035,
pointerEvents: 'none', pointerEvents: 'none',
mixBlendMode: 'overlay', mixBlendMode: isLight ? 'multiply' : 'overlay',
}} }}
/> />
{/* Layer 3: Aurora — blur merges orbs into organic glow */} {/* Layer 3: Aurora — blur 让 orb 融成有机晕染 */}
<canvas <canvas
ref={canvasRef} ref={canvasRef}
style={{ style={{
@ -234,7 +248,7 @@ export function AuroraCanvas() {
}} }}
/> />
{/* Layer 4: Top/bottom gradient mask */} {/* Layer 4: 顶/底渐变压角 */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
@ -242,7 +256,7 @@ export function AuroraCanvas() {
zIndex: 4, zIndex: 4,
pointerEvents: 'none', pointerEvents: 'none',
background: background:
'linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 18%, transparent 82%, rgba(0,0,0,0.5) 100%)', `linear-gradient(to bottom, ${fadeColor} 0%, transparent 18%, transparent 82%, ${fadeColor} 100%)`,
}} }}
/> />
</> </>

View File

@ -1,8 +1,8 @@
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 300; } .overlay { position: fixed; inset: 0; background: var(--color-modal-overlay); display: flex; align-items: center; justify-content: center; z-index: 300; }
.modal { background: #16161e; border: 1px solid var(--color-border-card); border-radius: var(--radius-card); padding: 24px; width: 400px; max-width: 90vw; } .modal { background: var(--color-bg-modal-glass); backdrop-filter: var(--bf-glass-lg); -webkit-backdrop-filter: var(--bf-glass-lg); border: 1px solid var(--color-border-card); border-radius: var(--radius-card); padding: 24px; width: 400px; max-width: 90vw; box-shadow: var(--shadow-glass-light); }
.title { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 12px; } .title { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 12px; }
.message { font-size: 14px; color: var(--color-text-secondary); line-height: 1.6; margin-bottom: 20px; } .message { font-size: 14px; color: var(--color-text-secondary); line-height: 1.6; margin-bottom: 20px; }
.actions { display: flex; justify-content: flex-end; gap: 8px; } .actions { display: flex; justify-content: flex-end; gap: 8px; }
.cancelBtn { padding: 8px 16px; background: transparent; border: 1px solid var(--color-border-card); border-radius: 8px; color: var(--color-text-secondary); font-size: 13px; cursor: pointer; } .cancelBtn { padding: 8px 16px; background: transparent; border: 1px solid var(--color-border-card); border-radius: 8px; color: var(--color-text-secondary); font-size: 13px; cursor: pointer; }
.confirmBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: #fff; font-size: 13px; cursor: pointer; } .confirmBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: var(--color-on-primary); font-size: 13px; cursor: pointer; }
.danger { background: var(--color-danger); } .danger { background: var(--color-danger); }

View File

@ -53,11 +53,14 @@
top: calc(100% + 4px); top: calc(100% + 4px);
left: 0; left: 0;
z-index: 1000; z-index: 1000;
background: #16161e; background: var(--color-bg-dropdown);
border: 1px solid var(--color-border-card); border: 1px solid var(--color-border-card);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); box-shadow:
backdrop-filter: blur(20px) saturate(180%); 0 8px 32px var(--color-shadow-dropdown),
inset 0 1px 0 var(--color-inset-highlight);
backdrop-filter: var(--bf-glass-md);
-webkit-backdrop-filter: var(--bf-glass-md);
padding: 12px; padding: 12px;
min-width: 280px; min-width: 280px;
} }
@ -82,7 +85,7 @@
} }
.navBtn:hover { .navBtn:hover {
background: rgba(255, 255, 255, 0.05); background: var(--color-inset-highlight);
color: var(--color-text-primary); color: var(--color-text-primary);
} }
@ -128,7 +131,7 @@
} }
.dayCell:hover { .dayCell:hover {
background: rgba(0, 184, 230, 0.12); background: var(--color-info-bg-soft);
} }
.otherMonth { .otherMonth {
@ -142,5 +145,5 @@
.selected { .selected {
background: var(--color-primary) !important; background: var(--color-primary) !important;
color: #fff !important; color: var(--color-on-primary) !important;
} }

View File

@ -7,16 +7,19 @@
bottom: calc(100% + 8px); bottom: calc(100% + 8px);
left: 0; left: 0;
background: var(--color-bg-dropdown); background: var(--color-bg-dropdown);
border: 1px solid var(--color-border-input-bar); border: 1px solid var(--color-border-card);
border-radius: var(--radius-dropdown); border-radius: var(--radius-dropdown);
padding: 6px; padding: 6px;
z-index: 100; z-index: 100;
backdrop-filter: blur(20px) saturate(180%); backdrop-filter: var(--bf-glass-md);
-webkit-backdrop-filter: var(--bf-glass-md);
opacity: 0; opacity: 0;
transform: translateY(8px); transform: translateY(8px);
pointer-events: none; pointer-events: none;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); box-shadow:
0 8px 32px var(--color-shadow-dropdown),
inset 0 1px 0 var(--color-inset-highlight);
} }
.open { .open {
@ -32,7 +35,7 @@
padding: 8px 12px; padding: 8px 12px;
border-radius: var(--radius-btn); border-radius: var(--radius-btn);
font-size: 13px; font-size: 13px;
color: #b0b0c0; color: var(--color-text-monochrome);
cursor: pointer; cursor: pointer;
transition: all 0.12s; transition: all 0.12s;
white-space: nowrap; white-space: nowrap;
@ -40,7 +43,7 @@
.item:hover { .item:hover {
background: var(--color-bg-hover); background: var(--color-bg-hover);
color: #fff; color: var(--color-text-primary);
} }
.item.selected { .item.selected {

View File

@ -2,7 +2,7 @@
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 60; z-index: 60;
background: rgba(0, 0, 0, 0.6); background: var(--color-modal-overlay);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
display: flex; display: flex;
@ -21,13 +21,13 @@
width: 100%; width: 100%;
max-width: 420px; max-width: 420px;
margin: 0 20px; margin: 0 20px;
background: rgba(255, 255, 255, 0.06); background: var(--color-bg-modal-glass);
backdrop-filter: blur(24px) saturate(180%); backdrop-filter: var(--bf-glass-xl);
-webkit-backdrop-filter: blur(24px) saturate(180%); -webkit-backdrop-filter: var(--bf-glass-xl);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid var(--color-border-card);
border-radius: 16px; border-radius: 16px;
padding: 36px 32px 32px; padding: 36px 32px 32px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05) inset; box-shadow: var(--shadow-glass-light);
animation: panelIn 0.3s ease-out; animation: panelIn 0.3s ease-out;
} }
@ -59,14 +59,14 @@
font-family: 'Space Grotesk', sans-serif; font-family: 'Space Grotesk', sans-serif;
font-size: 18px; font-size: 18px;
font-weight: 400; font-weight: 400;
color: #f1f0ff; color: var(--color-text-primary);
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.notice { .notice {
text-align: center; text-align: center;
font-size: 13px; font-size: 13px;
color: #8b8ea8; color: var(--color-text-secondary);
margin-bottom: 24px; margin-bottom: 24px;
line-height: 1.5; line-height: 1.5;
} }
@ -85,36 +85,36 @@
.label { .label {
font-size: 13px; font-size: 13px;
color: #8b8ea8; color: var(--color-text-secondary);
font-weight: 500; font-weight: 500;
} }
.input { .input {
height: 44px; height: 44px;
padding: 0 14px; padding: 0 14px;
background: rgba(255, 255, 255, 0.04); background: var(--color-bg-upload);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid var(--color-border-card);
border-radius: 10px; border-radius: 10px;
color: #f1f0ff; color: var(--color-text-primary);
font-size: 14px; font-size: 14px;
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s;
} }
.input::placeholder { .input::placeholder {
color: #4c4f6b; color: var(--color-text-disabled);
} }
.input:focus { .input:focus {
border-color: rgba(126, 220, 200, 0.5); border-color: var(--color-mint-accent);
} }
.error { .error {
color: #ff4d4f; color: var(--color-danger-text);
font-size: 13px; font-size: 13px;
text-align: center; text-align: center;
padding: 8px; padding: 8px;
background: rgba(255, 77, 79, 0.08); background: var(--color-danger-bg-soft);
border-radius: 8px; border-radius: 8px;
} }
@ -123,22 +123,24 @@
width: 55%; width: 55%;
align-self: center; align-self: center;
margin-top: 18px; margin-top: 18px;
background: rgba(120, 220, 200, 0.08); background: var(--color-mint-accent-bg);
border: 1px solid rgba(120, 220, 200, 0.3); border: 1px solid var(--color-mint-accent-border);
color: #7edcc8; color: var(--color-mint-accent);
border-radius: 10px; border-radius: 10px;
font-family: 'Space Grotesk', sans-serif; font-family: 'Space Grotesk', sans-serif;
font-size: 15px; font-size: 15px;
font-weight: 500; font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
backdrop-filter: blur(12px); backdrop-filter: var(--bf-glass-sm);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: var(--bf-glass-sm);
box-shadow: inset 0 1px 0 var(--color-inset-highlight);
} }
.submitBtn:hover { .submitBtn:hover {
background: rgba(120, 220, 200, 0.18); background: var(--color-mint-accent-bg-hover);
box-shadow: 0 0 24px rgba(120, 220, 200, 0.12); box-shadow: inset 0 1px 0 var(--color-inset-highlight), 0 0 24px var(--color-mint-accent-glow);
} }
.submitBtn:disabled { .submitBtn:disabled {

View File

@ -6,7 +6,7 @@
max-width: 1024px; max-width: 1024px;
width: 100%; width: 100%;
animation: cardFadeIn 0.3s ease-out; animation: cardFadeIn 0.3s ease-out;
border-bottom: 1px solid rgba(255, 255, 255, 0.06); border-bottom: 1px solid var(--color-border-soft);
} }
@keyframes cardFadeIn { @keyframes cardFadeIn {
@ -46,9 +46,9 @@
aspect-ratio: 3 / 4; aspect-ratio: 3 / 4;
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
background: #1a1a24; background: var(--color-bg-dropdown-elevated);
flex-shrink: 0; flex-shrink: 0;
border: 1px solid #2a2a38; border: 1px solid var(--color-border-modal);
} }
.audioThumb { .audioThumb {
@ -81,7 +81,7 @@
overflow: hidden; overflow: hidden;
} }
/* hover 展开黑底:基于 .header 定位,左边距图片 4px */ /* hover 展开 prompt 面板 — V2 玻璃面 */
.promptExpanded { .promptExpanded {
position: absolute; position: absolute;
top: 0; top: 0;
@ -91,20 +91,23 @@
color: var(--color-text-primary); color: var(--color-text-primary);
line-height: 1.6; line-height: 1.6;
word-break: break-word; word-break: break-word;
background: rgba(13, 13, 26, 0.95); background: var(--color-bg-glass-strong);
backdrop-filter: blur(12px); backdrop-filter: var(--bf-glass-sm);
border: 1px solid rgba(255, 255, 255, 0.10); -webkit-backdrop-filter: var(--bf-glass-sm);
border: 1px solid var(--color-border-card);
padding: 6px 8px; padding: 6px 8px;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); box-shadow:
0 8px 24px var(--color-shadow-dropdown),
inset 0 1px 0 var(--color-inset-highlight);
} }
.mentionTag { .mentionTag {
display: inline; display: inline;
padding: 1px 5px; padding: 1px 5px;
border-radius: 4px; border-radius: 4px;
background: rgba(108, 99, 255, 0.12); background: var(--color-mention-bg);
color: rgba(108, 99, 255, 0.7); color: var(--color-mention-text);
font-size: 13px; font-size: 13px;
white-space: nowrap; white-space: nowrap;
cursor: default; cursor: default;
@ -114,11 +117,11 @@
position: fixed; position: fixed;
z-index: 9999; z-index: 9999;
transform: translate(-50%, -100%); transform: translate(-50%, -100%);
background: #1e1e2e; background: var(--color-bg-modal-hover);
border: 1px solid #2a2a3a; border: 1px solid var(--color-border-modal);
border-radius: 10px; border-radius: 10px;
padding: 6px; padding: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); box-shadow: 0 8px 24px var(--color-overlay-soft);
pointer-events: none; pointer-events: none;
} }
@ -132,7 +135,7 @@
.mentionPreviewLabel { .mentionPreviewLabel {
text-align: center; text-align: center;
color: #8a8a9a; color: var(--color-text-secondary);
font-size: 11px; font-size: 11px;
margin-top: 4px; margin-top: 4px;
} }
@ -151,7 +154,7 @@
font-size: 12px; font-size: 12px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
padding: 1px 6px; padding: 1px 6px;
background: rgba(255, 255, 255, 0.06); background: var(--color-bg-card);
border-radius: 4px; border-radius: 4px;
white-space: nowrap; white-space: nowrap;
margin-left: 4px; margin-left: 4px;
@ -173,13 +176,17 @@
.detailTooltip { .detailTooltip {
position: fixed; position: fixed;
z-index: 1000; z-index: 1000;
background: rgba(13, 13, 26, 0.95); background: var(--color-bg-glass-strong);
backdrop-filter: blur(12px); backdrop-filter: var(--bf-glass-sm);
border: 1px solid rgba(255, 255, 255, 0.10); -webkit-backdrop-filter: var(--bf-glass-sm);
border: 1px solid var(--color-border-card);
border-radius: 8px; border-radius: 8px;
padding: 12px 20px; padding: 12px 20px;
min-width: 260px; min-width: 260px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); /* V2 玻璃面 — 阴影 + 顶边白高光 */
box-shadow:
0 8px 24px var(--color-shadow-dropdown),
inset 0 1px 0 var(--color-inset-highlight);
animation: detailTooltipFadeIn 0.15s ease-out; animation: detailTooltipFadeIn 0.15s ease-out;
} }
@ -214,7 +221,7 @@
.resultArea { .resultArea {
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
background: rgba(0, 0, 0, 0.3); background: var(--color-overlay-medium);
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
max-height: 320px; max-height: 320px;
display: flex; display: flex;
@ -256,16 +263,16 @@
width: 36px; width: 36px;
height: 36px; height: 36px;
border-radius: 50%; border-radius: 50%;
background: rgba(255, 255, 255, 0.15); background: var(--color-bg-on-media);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid var(--color-progress-track);
color: #fff; color: var(--color-on-overlay);
cursor: pointer; cursor: pointer;
transition: background 0.15s; transition: background 0.15s;
} }
.downloadBtn:hover { .downloadBtn:hover {
background: rgba(255, 255, 255, 0.25); background: var(--color-bg-on-media-hover);
} }
.resultPlaceholder { .resultPlaceholder {
@ -284,11 +291,11 @@
inset: 0; inset: 0;
background: linear-gradient( background: linear-gradient(
110deg, 110deg,
rgba(108, 99, 255, 0.03) 0%, var(--color-shimmer-purple-soft) 0%,
rgba(108, 99, 255, 0.08) 40%, var(--color-shimmer-purple-mid) 40%,
rgba(139, 92, 246, 0.12) 50%, var(--color-shimmer-purple-2-mid) 50%,
rgba(108, 99, 255, 0.08) 60%, var(--color-shimmer-purple-mid) 60%,
rgba(108, 99, 255, 0.03) 100% var(--color-shimmer-purple-soft) 100%
); );
background-size: 200% 100%; background-size: 200% 100%;
animation: shimmer 2.5s ease-in-out infinite; animation: shimmer 2.5s ease-in-out infinite;
@ -314,7 +321,7 @@
.loadingSpinner { .loadingSpinner {
width: 32px; width: 32px;
height: 32px; height: 32px;
border: 2.5px solid rgba(108, 99, 255, 0.15); border: 2.5px solid var(--color-mention-bg-active);
border-top-color: var(--color-primary); border-top-color: var(--color-primary);
border-radius: 50%; border-radius: 50%;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
@ -334,14 +341,14 @@
width: 100%; width: 100%;
max-width: 200px; max-width: 200px;
height: 3px; height: 3px;
background: rgba(255, 255, 255, 0.06); background: var(--color-bg-card);
border-radius: 2px; border-radius: 2px;
overflow: hidden; overflow: hidden;
} }
.progressFill { .progressFill {
height: 100%; height: 100%;
background: linear-gradient(90deg, var(--color-primary), #8b5cf6); background: linear-gradient(90deg, var(--color-primary), var(--color-primary-2));
border-radius: 2px; border-radius: 2px;
transition: width 1.5s ease-out; transition: width 1.5s ease-out;
} }
@ -353,7 +360,7 @@
/* Failed state — no video box, just text */ /* Failed state — no video box, just text */
.errorText { .errorText {
color: #e74c3c; color: var(--color-danger);
font-size: 13px; font-size: 13px;
line-height: 1.5; line-height: 1.5;
padding: 8px 0; padding: 8px 0;
@ -373,22 +380,22 @@
border-radius: 8px; border-radius: 8px;
font-size: 13px; font-size: 13px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
background: rgba(255, 255, 255, 0.04); background: var(--color-bg-upload);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid var(--color-border-upload);
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
font-family: inherit; font-family: inherit;
} }
.actionBtn:hover { .actionBtn:hover {
background: rgba(255, 255, 255, 0.08); background: var(--color-bg-hover);
color: var(--color-text-primary); color: var(--color-text-primary);
} }
.deleteBtn:hover { .deleteBtn:hover {
color: #ff6b6b; color: var(--color-danger-hover);
border-color: rgba(255, 107, 107, 0.3); border-color: var(--color-danger-hover-border);
background: rgba(255, 107, 107, 0.08); background: var(--color-danger-hover-bg);
} }
/* More menu */ /* More menu */
@ -403,28 +410,32 @@
padding: 6px; padding: 6px;
border-radius: 8px; border-radius: 8px;
color: var(--color-text-disabled); color: var(--color-text-disabled);
background: rgba(255, 255, 255, 0.04); background: var(--color-bg-upload);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid var(--color-border-upload);
cursor: pointer; cursor: pointer;
transition: color 0.15s, background 0.15s, border-color 0.15s; transition: color 0.15s, background 0.15s, border-color 0.15s;
} }
.moreBtn:hover { .moreBtn:hover {
color: var(--color-text-secondary); color: var(--color-text-secondary);
background: rgba(255, 255, 255, 0.08); background: var(--color-bg-hover);
border-color: rgba(255, 255, 255, 0.15); border-color: var(--color-border-glass-hover);
} }
.moreDropdown { .moreDropdown {
position: absolute; position: absolute;
bottom: calc(100% + 6px); bottom: calc(100% + 6px);
right: 0; right: 0;
background: rgba(13, 13, 26, 0.95); background: var(--color-bg-glass-strong);
backdrop-filter: blur(20px) saturate(180%); backdrop-filter: var(--bf-glass-md);
border: 1px solid rgba(255, 255, 255, 0.10); -webkit-backdrop-filter: var(--bf-glass-md);
border: 1px solid var(--color-border-card);
border-radius: 10px; border-radius: 10px;
padding: 4px; padding: 4px;
min-width: 100px; min-width: 100px;
z-index: 10; z-index: 10;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); /* V2 玻璃面 — 阴影 + 顶边白高光 */
box-shadow:
0 8px 24px var(--color-shadow-dropdown),
inset 0 1px 0 var(--color-inset-highlight-strong);
animation: dropdownFadeIn 0.12s ease-out; animation: dropdownFadeIn 0.12s ease-out;
} }
@ -441,7 +452,7 @@
padding: 8px 14px; padding: 8px 14px;
background: none; background: none;
border: none; border: none;
color: #ff6b6b; color: var(--color-danger-hover);
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
border-radius: 6px; border-radius: 6px;
@ -451,6 +462,6 @@
transition: background 0.12s; transition: background 0.12s;
} }
.moreDropdown button:hover { .moreDropdown button:hover {
background: rgba(255, 107, 107, 0.10); background: var(--color-danger-hover-bg-strong);
} }

View File

@ -2,7 +2,7 @@
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 400; z-index: 400;
background: rgba(0, 0, 0, 0.85); background: var(--color-overlay-deep);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View File

@ -18,20 +18,20 @@ export function InputBar({ scrollBottomBtn }: { scrollBottomBtn?: React.ReactNod
e.preventDefault(); e.preventDefault();
// 只有外部文件拖入时才显示蓝色边框(内部 mention 标签拖拽不触发) // 只有外部文件拖入时才显示蓝色边框(内部 mention 标签拖拽不触发)
if (e.dataTransfer.types.includes('Files') && barRef.current) { if (e.dataTransfer.types.includes('Files') && barRef.current) {
barRef.current.style.borderColor = '#00b8e6'; barRef.current.style.borderColor = 'var(--color-info)';
} }
}, []); }, []);
const handleDragLeave = useCallback(() => { const handleDragLeave = useCallback(() => {
if (barRef.current) { if (barRef.current) {
barRef.current.style.borderColor = '#2a2a38'; barRef.current.style.borderColor = 'var(--color-border-modal)';
} }
}, []); }, []);
const handleDrop = useCallback((e: DragEvent) => { const handleDrop = useCallback((e: DragEvent) => {
e.preventDefault(); e.preventDefault();
if (barRef.current) { if (barRef.current) {
barRef.current.style.borderColor = '#2a2a38'; barRef.current.style.borderColor = 'var(--color-border-modal)';
} }
const IMAGE_MAX = 30 * 1024 * 1024; const IMAGE_MAX = 30 * 1024 * 1024;
const VIDEO_MAX = 50 * 1024 * 1024; const VIDEO_MAX = 50 * 1024 * 1024;
@ -120,10 +120,10 @@ export function InputBar({ scrollBottomBtn }: { scrollBottomBtn?: React.ReactNod
onClick={() => { if (!searchDisabled) setSearchMode(searchMode === 'smart' ? 'off' : 'smart'); }} onClick={() => { if (!searchDisabled) setSearchMode(searchMode === 'smart' ? 'off' : 'smart'); }}
title={searchDisabled ? '联网搜索仅支持纯文生视频' : ''} title={searchDisabled ? '联网搜索仅支持纯文生视频' : ''}
style={{ style={{
background: searchMode === 'smart' && !searchDisabled ? 'rgba(108, 99, 255, 0.12)' : 'transparent', background: searchMode === 'smart' && !searchDisabled ? 'var(--color-mention-bg)' : 'transparent',
border: `1px solid ${searchMode === 'smart' && !searchDisabled ? 'var(--color-primary)' : 'var(--color-border-card)'}`, border: `1px solid ${searchMode === 'smart' && !searchDisabled ? 'var(--color-primary)' : 'var(--color-border-card)'}`,
borderRadius: 6, padding: '4px 12px', fontSize: 12, borderRadius: 6, padding: '4px 12px', fontSize: 12,
color: searchDisabled ? '#3a3a4a' : searchMode === 'smart' ? 'var(--color-primary)' : 'var(--color-text-secondary)', color: searchDisabled ? 'var(--color-btn-send-disabled)' : searchMode === 'smart' ? 'var(--color-primary)' : 'var(--color-text-secondary)',
cursor: searchDisabled ? 'not-allowed' : 'pointer', transition: 'all 0.15s', cursor: searchDisabled ? 'not-allowed' : 'pointer', transition: 'all 0.15s',
opacity: searchDisabled ? 0.5 : 1, opacity: searchDisabled ? 0.5 : 1,
}} }}
@ -138,7 +138,7 @@ export function InputBar({ scrollBottomBtn }: { scrollBottomBtn?: React.ReactNod
background: 'transparent', background: 'transparent',
border: '1px solid var(--color-border-card)', border: '1px solid var(--color-border-card)',
borderRadius: 6, padding: '4px 12px', fontSize: 12, borderRadius: 6, padding: '4px 12px', fontSize: 12,
color: '#3a3a4a', cursor: 'not-allowed', transition: 'all 0.15s', color: 'var(--color-btn-send-disabled)', cursor: 'not-allowed', transition: 'all 0.15s',
opacity: 0.5, opacity: 0.5,
}} }}
> >

View File

@ -12,8 +12,8 @@
.trigger { .trigger {
height: var(--thumbnail-size); height: var(--thumbnail-size);
aspect-ratio: 3 / 4; aspect-ratio: 3 / 4;
border: 1.5px dashed #3a3a48; border: 1.5px dashed var(--color-border-modal);
background: rgba(255, 255, 255, 0.03); background: var(--color-bg-upload);
border-radius: var(--radius-btn); border-radius: var(--radius-btn);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -26,8 +26,8 @@
} }
.trigger:hover { .trigger:hover {
border-color: #5a5a6a; border-color: var(--color-border-modal-hover);
background: rgba(255, 255, 255, 0.06); background: var(--color-bg-card);
} }
.triggerText { .triggerText {
@ -51,7 +51,7 @@
aspect-ratio: 3 / 4; aspect-ratio: 3 / 4;
border-radius: var(--radius-thumbnail); border-radius: var(--radius-thumbnail);
overflow: hidden; overflow: hidden;
background: #1a1a24; background: var(--color-bg-dropdown-elevated);
flex-shrink: 0; flex-shrink: 0;
} }
@ -68,7 +68,7 @@
right: 4px; right: 4px;
width: 18px; width: 18px;
height: 18px; height: 18px;
background: rgba(0, 0, 0, 0.7); background: var(--color-overlay-strong);
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
@ -90,6 +90,6 @@
padding: 2px 0; padding: 2px 0;
text-align: center; text-align: center;
font-size: 10px; font-size: 10px;
color: #fff; color: var(--color-on-overlay);
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); background: linear-gradient(transparent, var(--color-overlay-strong));
} }

View File

@ -2,7 +2,7 @@
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 50; z-index: 50;
background: rgba(0, 0, 0, 0.5); background: var(--color-modal-overlay);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
display: flex; display: flex;
@ -21,13 +21,13 @@
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
margin: 0 20px; margin: 0 20px;
background: rgba(255, 255, 255, 0.06); background: var(--color-bg-modal-glass);
backdrop-filter: blur(24px) saturate(180%); backdrop-filter: var(--bf-glass-xl);
-webkit-backdrop-filter: blur(24px) saturate(180%); -webkit-backdrop-filter: var(--bf-glass-xl);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid var(--color-border-card);
border-radius: 16px; border-radius: 16px;
padding: 36px 32px 32px; padding: 36px 32px 32px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05) inset; box-shadow: var(--shadow-glass-light);
animation: panelIn 0.3s ease-out; animation: panelIn 0.3s ease-out;
} }
@ -53,15 +53,15 @@
justify-content: center; justify-content: center;
background: none; background: none;
border: none; border: none;
color: rgba(255, 255, 255, 0.4); color: var(--color-text-on-glass-faint);
cursor: pointer; cursor: pointer;
border-radius: 6px; border-radius: 6px;
transition: all 0.2s; transition: all 0.2s;
} }
.closeBtn:hover { .closeBtn:hover {
color: rgba(255, 255, 255, 0.8); color: var(--color-text-on-glass);
background: rgba(255, 255, 255, 0.06); background: var(--color-bg-card);
} }
.header { .header {
@ -81,7 +81,7 @@
font-family: 'Space Grotesk', sans-serif; font-family: 'Space Grotesk', sans-serif;
font-size: 18px; font-size: 18px;
font-weight: 400; font-weight: 400;
color: #f1f0ff; color: var(--color-text-primary);
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
@ -99,36 +99,36 @@
.label { .label {
font-size: 13px; font-size: 13px;
color: #8b8ea8; color: var(--color-text-secondary);
font-weight: 500; font-weight: 500;
} }
.input { .input {
height: 44px; height: 44px;
padding: 0 14px; padding: 0 14px;
background: rgba(255, 255, 255, 0.04); background: var(--color-bg-upload);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid var(--color-border-card);
border-radius: 10px; border-radius: 10px;
color: #f1f0ff; color: var(--color-text-primary);
font-size: 14px; font-size: 14px;
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s;
} }
.input::placeholder { .input::placeholder {
color: #4c4f6b; color: var(--color-text-disabled);
} }
.input:focus { .input:focus {
border-color: rgba(126, 220, 200, 0.5); border-color: var(--color-mint-accent);
} }
.error { .error {
color: #ff4d4f; color: var(--color-danger-text);
font-size: 13px; font-size: 13px;
text-align: center; text-align: center;
padding: 8px; padding: 8px;
background: rgba(255, 77, 79, 0.08); background: var(--color-danger-bg-soft);
border-radius: 8px; border-radius: 8px;
} }
@ -137,22 +137,25 @@
width: 55%; width: 55%;
align-self: center; align-self: center;
margin-top: 18px; margin-top: 18px;
background: rgba(120, 220, 200, 0.08); background: var(--color-mint-accent-bg);
border: 1px solid rgba(120, 220, 200, 0.3); border: 1px solid var(--color-mint-accent-border);
color: #7edcc8; color: var(--color-mint-accent);
border-radius: 10px; border-radius: 10px;
font-family: 'Space Grotesk', sans-serif; font-family: 'Space Grotesk', sans-serif;
font-size: 15px; font-size: 15px;
font-weight: 500; font-weight: 600; /* V2: 500 → 600,浅色下提对比度 */
letter-spacing: 0.04em;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
backdrop-filter: blur(12px); backdrop-filter: var(--bf-glass-sm);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: var(--bf-glass-sm);
/* V2 玻璃顶边白高光 */
box-shadow: inset 0 1px 0 var(--color-inset-highlight);
} }
.submitBtn:hover { .submitBtn:hover {
background: rgba(120, 220, 200, 0.18); background: var(--color-mint-accent-bg-hover);
box-shadow: 0 0 24px rgba(120, 220, 200, 0.12); box-shadow: inset 0 1px 0 var(--color-inset-highlight), 0 0 24px var(--color-mint-accent-glow);
} }
.submitBtn:disabled { .submitBtn:disabled {
@ -162,7 +165,7 @@
.hint { .hint {
font-size: 12px; font-size: 12px;
color: rgba(255, 255, 255, 0.25); color: var(--color-text-on-glass-faint);
text-align: center; text-align: center;
margin: 0; margin: 0;
} }

View File

@ -24,7 +24,7 @@
position: absolute; position: absolute;
top: 4px; top: 4px;
left: 0; left: 0;
color: #5a5a6a; color: var(--color-border-modal-hover);
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
pointer-events: none; pointer-events: none;
@ -38,8 +38,8 @@
padding: 1px 6px; padding: 1px 6px;
margin: 0 2px; margin: 0 2px;
border-radius: 4px; border-radius: 4px;
background: rgba(108, 99, 255, 0.12); background: var(--color-mention-bg);
color: rgba(108, 99, 255, 0.7); color: var(--color-mention-text);
font-size: 13px; font-size: 13px;
cursor: grab; cursor: grab;
user-select: none; user-select: none;
@ -74,22 +74,26 @@
} }
.mention:hover { .mention:hover {
background: rgba(108, 99, 255, 0.22); background: var(--color-mention-bg-hover);
color: rgba(108, 99, 255, 0.9); color: var(--color-mention-text-hover);
} }
/* Mention popup — appears above cursor */ /* Mention popup — appears above cursor (V2 玻璃面) */
.mentionPopup { .mentionPopup {
position: absolute; position: absolute;
z-index: 100; z-index: 100;
background: rgba(13, 13, 26, 0.92); background: var(--color-bg-glass-strong);
border: 1px solid rgba(255, 255, 255, 0.10); border: 1px solid var(--color-border-card);
border-radius: 10px; border-radius: 10px;
padding: 6px; padding: 6px;
min-width: 200px; min-width: 200px;
max-width: 280px; max-width: 280px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); /* 阴影 + 玻璃顶边白高光 */
backdrop-filter: blur(20px) saturate(180%); box-shadow:
0 8px 24px var(--color-shadow-dropdown),
inset 0 1px 0 var(--color-inset-highlight-strong);
backdrop-filter: var(--bf-glass-md);
-webkit-backdrop-filter: var(--bf-glass-md);
transform: translateY(-100%); transform: translateY(-100%);
animation: fadeInUp 0.12s ease; animation: fadeInUp 0.12s ease;
} }
@ -102,8 +106,8 @@
.mentionHeader { .mentionHeader {
padding: 4px 8px 6px; padding: 4px 8px 6px;
font-size: 11px; font-size: 11px;
color: #5a5a6a; color: var(--color-border-modal-hover);
border-bottom: 1px solid #2a2a3a; border-bottom: 1px solid var(--color-border-modal);
margin-bottom: 4px; margin-bottom: 4px;
} }
@ -122,12 +126,12 @@
} }
.mentionItem:hover { .mentionItem:hover {
background: rgba(255, 255, 255, 0.06); background: var(--color-bg-card);
} }
.mentionItemActive { .mentionItemActive {
background: rgba(108, 99, 255, 0.15); background: var(--color-mention-bg-active);
color: #f1f0ff; color: var(--color-text-primary);
} }
.mentionThumb { .mentionThumb {
@ -136,7 +140,7 @@
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
flex-shrink: 0; flex-shrink: 0;
background: #2a2a3a; background: var(--color-border-modal);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -155,7 +159,7 @@
} }
.mentionType { .mentionType {
color: #5a5a6a; color: var(--color-border-modal-hover);
font-size: 11px; font-size: 11px;
} }
@ -164,11 +168,11 @@
position: absolute; position: absolute;
z-index: 200; z-index: 200;
transform: translate(-50%, -100%); transform: translate(-50%, -100%);
background: #1e1e2e; background: var(--color-bg-modal-hover);
border: 1px solid #2a2a3a; border: 1px solid var(--color-border-modal);
border-radius: 10px; border-radius: 10px;
padding: 6px; padding: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); box-shadow: 0 8px 24px var(--color-overlay-soft);
pointer-events: none; pointer-events: none;
animation: fadeIn 0.1s ease; animation: fadeIn 0.1s ease;
} }
@ -183,7 +187,7 @@
.previewLabel { .previewLabel {
text-align: center; text-align: center;
color: #8a8a9a; color: var(--color-text-secondary);
font-size: 11px; font-size: 11px;
margin-top: 4px; margin-top: 4px;
} }

View File

@ -754,7 +754,7 @@ export function PromptInput() {
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<span className={styles.mentionLabel}>{asset.name}</span> <span className={styles.mentionLabel}>{asset.name}</span>
<span style={{ fontSize: 10, color: '#5a5a6a', marginLeft: 4 }}>{asset.group_name}</span> <span style={{ fontSize: 10, color: 'var(--color-border-modal-hover)', marginLeft: 4 }}>{asset.group_name}</span>
</div> </div>
<span className={styles.mentionType}> <span className={styles.mentionType}>
{asset.asset_type === 'Video' ? '视频' : asset.asset_type === 'Audio' ? '音频' : '图片'} {asset.asset_type === 'Video' ? '视频' : asset.asset_type === 'Audio' ? '音频' : '图片'}

View File

@ -1,11 +1,12 @@
import type { AdminRecord } from '../types'; import type { AdminRecord } from '../types';
import { ReferenceList } from './ReferenceList'; import { ReferenceList } from './ReferenceList';
import { rewriteTosUrl } from '../lib/api';
const STATUS_MAP: Record<string, { label: string; color: string; bg: string }> = { const STATUS_MAP: Record<string, { label: string; color: string; bg: string }> = {
completed: { label: '已完成', color: '#00b894', bg: 'rgba(0,184,148,0.15)' }, completed: { label: '已完成', color: 'var(--color-success)', bg: 'var(--color-success-bg)' },
failed: { label: '失败', color: '#e74c3c', bg: 'rgba(231,76,60,0.15)' }, failed: { label: '失败', color: 'var(--color-danger)', bg: 'var(--color-danger-bg)' },
processing: { label: '生成中', color: '#00b8e6', bg: 'rgba(0,184,230,0.15)' }, processing: { label: '生成中', color: 'var(--color-info)', bg: 'var(--color-info-bg)' },
queued: { label: '排队中', color: '#00b8e6', bg: 'rgba(0,184,230,0.15)' }, queued: { label: '排队中', color: 'var(--color-info)', bg: 'var(--color-info-bg)' },
}; };
const MODE_MAP: Record<string, string> = { universal: '全能参考', keyframe: '首尾帧' }; const MODE_MAP: Record<string, string> = { universal: '全能参考', keyframe: '首尾帧' };
@ -39,11 +40,27 @@ export function RecordDetailModal({ record: r, onClose, showTeam, showCost }: Pr
<div style={modal} onClick={(e) => e.stopPropagation()}> <div style={modal} onClick={(e) => e.stopPropagation()}>
{/* Header */} {/* Header */}
<div style={header}> <div style={header}>
<span style={{ fontSize: 16, fontWeight: 600, color: '#e2e2ea' }}></span> <span style={{ fontSize: 16, fontWeight: 600, color: 'var(--color-text-light)' }}></span>
<button style={closeBtn} onClick={onClose}></button> <button style={closeBtn} onClick={onClose}></button>
</div> </div>
{/* Body — 左:视频+参考素材 / 右:信息+提示词 */}
<div style={body}> <div style={body}>
{/* ── 左侧:视频 + 参考素材 ── */}
<div style={mediaPanel}>
<MediaArea record={r} />
{refs.length > 0 && (
<>
<div style={sectionTitle}>({refs.length})</div>
<div style={refScrollBox}>
<ReferenceList references={refs} />
</div>
</>
)}
</div>
{/* ── 右侧:信息 + 提示词 ── */}
<div style={infoPanel}>
{/* Status */} {/* Status */}
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<span style={{ ...statusBadge, color: st.color, background: st.bg }}>{st.label}</span> <span style={{ ...statusBadge, color: st.color, background: st.bg }}>{st.label}</span>
@ -55,15 +72,15 @@ export function RecordDetailModal({ record: r, onClose, showTeam, showCost }: Pr
<div style={{ fontWeight: 500, marginBottom: 4 }}></div> <div style={{ fontWeight: 500, marginBottom: 4 }}></div>
<div>{r.error_message}</div> <div>{r.error_message}</div>
{r.raw_error && r.raw_error !== r.error_message && ( {r.raw_error && r.raw_error !== r.error_message && (
<div style={{ marginTop: 8, fontSize: 11, color: '#888', fontFamily: 'monospace', wordBreak: 'break-all' }}> <div style={{ marginTop: 8, fontSize: 11, color: 'var(--color-text-tertiary)', fontFamily: 'monospace', wordBreak: 'break-all' }}>
{r.raw_error} :{r.raw_error}
</div> </div>
)} )}
</div> </div>
)} )}
{/* Info Grid */} {/* Info Grid */}
<div style={sectionTitle}></div> <div style={{ ...sectionTitle, marginTop: 0 }}></div>
<div style={infoGrid}> <div style={infoGrid}>
{r.ark_task_id && <InfoItem label="任务ID" value={r.ark_task_id} />} {r.ark_task_id && <InfoItem label="任务ID" value={r.ark_task_id} />}
{r.username && <InfoItem label="用户" value={r.username} />} {r.username && <InfoItem label="用户" value={r.username} />}
@ -82,67 +99,268 @@ export function RecordDetailModal({ record: r, onClose, showTeam, showCost }: Pr
{/* Prompt */} {/* Prompt */}
<div style={sectionTitle}></div> <div style={sectionTitle}></div>
<div style={promptBox}>{r.prompt || '(无提示词)'}</div> <div style={promptBox}>{r.prompt || '(无提示词)'}</div>
</div>
{/* References */} </div>
{refs.length > 0 && ( </div>
<> </div>
<div style={sectionTitle}>{refs.length}</div>
<ReferenceList references={refs} />
</> </>
);
}
/**
* :
* - completed + result_url (controls,)
* - completed - result_url "视频已生成"
* - failed RGB "生成失败" + +
* - processing / queued spinner +
*/
function MediaArea({ record: r }: { record: AdminRecord }) {
return (
<div style={mediaFrame}>
{r.status === 'completed' && r.result_url ? (
<video
src={rewriteTosUrl(r.result_url)}
style={mediaVideo}
controls
preload="metadata"
/>
) : r.status === 'completed' ? (
<div style={mediaPlaceholder}>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polygon points="23 7 16 12 23 17 23 7"/>
<rect x="1" y="5" width="15" height="14" rx="2"/>
</svg>
<span></span>
</div>
) : r.status === 'failed' ? (
<FailureGlitch errorMessage={r.error_message} />
) : (
<div style={mediaPlaceholder}>
<svg className="spinner" width="44" height="44" viewBox="0 0 50 50" style={spinnerStyle}>
<circle cx="25" cy="25" r="20" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeDasharray="80 40" />
</svg>
<span>{r.status === 'queued' ? '排队中' : '生成中'}</span>
{/* 内联 keyframes — 没有 module.css */}
<style>{`@keyframes shoot-spin { to { transform: rotate(360deg); } }`}</style>
</div>
)} )}
</div> </div>
);
}
/**
* RGB "生成失败" cyan/magenta text-shadow
* CRT ;
*/
function FailureGlitch({ errorMessage }: { errorMessage?: string }) {
const msg = (errorMessage || 'Generation failed').slice(0, 80);
return (
<div style={failBg}>
<div style={failStripe} aria-hidden="true" />
<div style={glitchTitle}></div>
<div style={glitchSub}>{msg}</div>
<div style={{ ...failStripe, top: 'auto', bottom: 0 }} aria-hidden="true" />
</div> </div>
</div>
</>
); );
} }
function InfoItem({ label, value }: { label: string; value: string }) { function InfoItem({ label, value }: { label: string; value: string }) {
return ( return (
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div style={{ fontSize: 11, color: '#888', marginBottom: 2 }}>{label}</div> <div style={{ fontSize: 11, color: 'var(--color-text-tertiary)', marginBottom: 2 }}>{label}</div>
<div style={{ fontSize: 13, color: '#e2e2ea', wordBreak: 'break-all' }}>{value}</div> <div style={{ fontSize: 13, color: 'var(--color-text-light)', wordBreak: 'break-all' }}>{value}</div>
</div> </div>
); );
} }
// ─────────────────────────────────────
// Styles // Styles
// ─────────────────────────────────────
const overlay: React.CSSProperties = { const overlay: React.CSSProperties = {
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', position: 'fixed', inset: 0, background: 'var(--color-modal-overlay)', display: 'flex',
alignItems: 'center', justifyContent: 'center', zIndex: 10000, alignItems: 'center', justifyContent: 'center', zIndex: 10000,
}; };
const modal: React.CSSProperties = { const modal: React.CSSProperties = {
background: '#111118', border: '1px solid #2a2a38', borderRadius: 12, background: 'var(--color-bg-modal-glass)',
width: 560, maxHeight: '80vh', display: 'flex', flexDirection: 'column', backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: '1px solid var(--color-border-modal-soft)',
borderRadius: 12,
width: 1080,
maxWidth: '95vw',
maxHeight: '85vh',
display: 'flex',
flexDirection: 'column',
boxShadow: 'var(--shadow-glass-light)',
}; };
const header: React.CSSProperties = { const header: React.CSSProperties = {
display: 'flex', justifyContent: 'space-between', alignItems: 'center', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '16px 20px', borderBottom: '1px solid #2a2a38', padding: '16px 24px', borderBottom: '1px solid var(--color-border-modal-soft)',
flexShrink: 0,
}; };
const closeBtn: React.CSSProperties = { const closeBtn: React.CSSProperties = {
background: 'none', border: 'none', color: '#888', fontSize: 16, cursor: 'pointer', background: 'none', border: 'none', color: 'var(--color-text-tertiary)', fontSize: 16, cursor: 'pointer',
padding: '4px 8px', borderRadius: 4, padding: '4px 8px', borderRadius: 4,
}; };
const body: React.CSSProperties = { const body: React.CSSProperties = {
padding: 20, overflowY: 'auto', flex: 1, flex: 1,
display: 'flex',
gap: 24,
padding: 24,
overflow: 'hidden', /* infoPanel 内部滚动 */
minHeight: 0,
}; };
/* 左侧媒体区 — 固定 480 宽,视频 + 参考素材纵向排列 */
const mediaPanel: React.CSSProperties = {
width: 480,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
minHeight: 0, /* 给内部 flex 让出收缩空间 */
};
/* :max-height + ,
Seedance 9 80px thumb × 5/ = 1-2 ,250px */
const refScrollBox: React.CSSProperties = {
maxHeight: 250,
overflowY: 'auto',
marginTop: 8,
paddingRight: 4,
};
const mediaFrame: React.CSSProperties = {
width: '100%',
aspectRatio: '16 / 9',
maxHeight: 360,
background: 'var(--color-bg-video)',
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid var(--color-border-modal-soft)',
};
const mediaVideo: React.CSSProperties = {
width: '100%',
height: '100%',
objectFit: 'contain',
display: 'block',
};
const mediaPlaceholder: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
color: 'var(--color-text-on-glass-soft)',
fontSize: 13,
};
const spinnerStyle: React.CSSProperties = {
color: 'var(--color-primary)',
animation: 'shoot-spin 1s linear infinite',
};
/* ── 失败态:RGB 故障字 + 斜纹底 ── */
const failBg: React.CSSProperties = {
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 18,
/* 半透明红斜纹 — 仿 CRT 信号丢失 */
background: `
repeating-linear-gradient(135deg,
var(--color-danger-bg-soft) 0,
var(--color-danger-bg-soft) 14px,
transparent 14px,
transparent 22px),
var(--color-bg-elevated)
`,
};
const failStripe: React.CSSProperties = {
position: 'absolute',
top: 0, left: 0, right: 0,
height: 6,
background: `repeating-linear-gradient(90deg,
var(--color-danger) 0,
var(--color-danger) 6px,
var(--color-info) 6px,
var(--color-info) 8px,
transparent 8px,
transparent 14px)`,
opacity: 0.6,
};
const glitchTitle: React.CSSProperties = {
fontSize: 44,
fontWeight: 700,
letterSpacing: '0.12em',
color: 'var(--color-danger)',
fontFamily: "'Space Grotesk', 'JetBrains Mono', ui-monospace, monospace",
/* RGB 偏移:左 cyan 右 magenta */
textShadow: `
-2px 0 var(--color-info),
2px 0 #ff00aa,
0 0 30px var(--color-danger-bg)
`,
textAlign: 'center',
userSelect: 'none',
};
const glitchSub: React.CSSProperties = {
fontSize: 12,
color: 'var(--color-danger)',
opacity: 0.85,
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
maxWidth: '85%',
textAlign: 'center',
wordBreak: 'break-word',
lineHeight: 1.6,
};
/* 右侧信息区 — 现有内容整体搬过来 */
const infoPanel: React.CSSProperties = {
flex: 1,
minWidth: 0,
overflowY: 'auto',
paddingRight: 4, /* 给滚动条让位 */
};
const statusBadge: React.CSSProperties = { const statusBadge: React.CSSProperties = {
padding: '4px 12px', borderRadius: 6, fontSize: 13, fontWeight: 500, padding: '4px 12px', borderRadius: 6, fontSize: 13, fontWeight: 500,
}; };
const errorBox: React.CSSProperties = { const errorBox: React.CSSProperties = {
background: 'rgba(231,76,60,0.08)', border: '1px solid rgba(231,76,60,0.2)', background: 'var(--color-danger-bg-soft)', border: '1px solid var(--color-danger-border)',
borderRadius: 8, padding: 12, marginBottom: 16, fontSize: 13, color: '#e74c3c', borderRadius: 8, padding: 12, marginBottom: 16, fontSize: 13, color: 'var(--color-danger)',
}; };
const sectionTitle: React.CSSProperties = { const sectionTitle: React.CSSProperties = {
fontSize: 12, color: '#888', fontWeight: 500, marginBottom: 8, marginTop: 16, fontSize: 12, color: 'var(--color-text-tertiary)', fontWeight: 500, marginBottom: 8, marginTop: 16,
textTransform: 'uppercase', letterSpacing: 1, textTransform: 'uppercase', letterSpacing: 1,
}; };
const infoGrid: React.CSSProperties = { const infoGrid: React.CSSProperties = {
display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px 16px', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px 16px',
}; };
const promptBox: React.CSSProperties = { const promptBox: React.CSSProperties = {
background: '#0a0a0f', borderRadius: 8, padding: 12, fontSize: 13, background: 'var(--color-bg-elevated)', borderRadius: 8, padding: 12, fontSize: 13,
color: '#ccc', lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-all', color: 'var(--color-text-monochrome)', lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
maxHeight: 150, overflowY: 'auto', maxHeight: 150, overflowY: 'auto',
}; };

View File

@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { createPortal } from 'react-dom';
interface RefItem { interface RefItem {
type?: string; type?: string;
@ -40,7 +41,7 @@ export function ReferenceList({ references }: Props) {
const hasUrl = fullUrl && !fullUrl.startsWith('asset://'); const hasUrl = fullUrl && !fullUrl.startsWith('asset://');
return ( return (
<div key={i} style={refItem}> <div key={i} style={refItem} title={label}>
{/* Thumbnail area */} {/* Thumbnail area */}
<div style={thumbWrap}> <div style={thumbWrap}>
{isAudio ? ( {isAudio ? (
@ -78,16 +79,19 @@ export function ReferenceList({ references }: Props) {
})} })}
</div> </div>
{/* Image lightbox */} {/* Image lightbox Portal body backdrop-filter stacking context,
{lightboxUrl && ( modal inner , modal modal close
<div style={overlay} onClick={() => setLightboxUrl(null)}> stopPropagation React */}
{lightboxUrl && createPortal(
<div style={overlay} onClick={(e) => { e.stopPropagation(); setLightboxUrl(null); }}>
<img src={lightboxUrl} alt="" style={{ maxWidth: '90vw', maxHeight: '90vh', borderRadius: 8 }} /> <img src={lightboxUrl} alt="" style={{ maxWidth: '90vw', maxHeight: '90vh', borderRadius: 8 }} />
</div> </div>,
document.body
)} )}
{/* Video/Audio player modal */} {/* Video/Audio player modal — 同 Portal 处理 */}
{playingMedia && ( {playingMedia && createPortal(
<div style={overlay} onClick={() => setPlayingMedia(null)}> <div style={overlay} onClick={(e) => { e.stopPropagation(); setPlayingMedia(null); }}>
<div style={playerWrap} onClick={(e) => e.stopPropagation()}> <div style={playerWrap} onClick={(e) => e.stopPropagation()}>
<button style={playerClose} onClick={() => setPlayingMedia(null)}></button> <button style={playerClose} onClick={() => setPlayingMedia(null)}></button>
{playingMedia.type === 'video' ? ( {playingMedia.type === 'video' ? (
@ -104,7 +108,8 @@ export function ReferenceList({ references }: Props) {
</div> </div>
)} )}
</div> </div>
</div> </div>,
document.body
)} )}
</> </>
); );
@ -112,7 +117,7 @@ export function ReferenceList({ references }: Props) {
// Styles // Styles
const overlay: React.CSSProperties = { const overlay: React.CSSProperties = {
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex', position: 'fixed', inset: 0, background: 'var(--color-overlay-strong)', display: 'flex',
alignItems: 'center', justifyContent: 'center', zIndex: 10002, alignItems: 'center', justifyContent: 'center', zIndex: 10002,
}; };
const refsGrid: React.CSSProperties = { const refsGrid: React.CSSProperties = {
@ -126,34 +131,34 @@ const thumbWrap: React.CSSProperties = {
}; };
const refImgStyle: React.CSSProperties = { const refImgStyle: React.CSSProperties = {
width: 80, height: 80, objectFit: 'cover', borderRadius: 6, cursor: 'pointer', width: 80, height: 80, objectFit: 'cover', borderRadius: 6, cursor: 'pointer',
border: '1px solid #2a2a38', border: '1px solid var(--color-border-modal)',
}; };
const placeholder: React.CSSProperties = { const placeholder: React.CSSProperties = {
width: 80, height: 80, borderRadius: 6, background: '#1a1a2e', width: 80, height: 80, borderRadius: 6, background: 'var(--color-bg-placeholder)',
border: '1px solid #2a2a38', display: 'flex', alignItems: 'center', border: '1px solid var(--color-border-modal)', display: 'flex', alignItems: 'center',
justifyContent: 'center', fontSize: 24, color: '#888', justifyContent: 'center', fontSize: 24, color: 'var(--color-text-tertiary)',
}; };
const downloadBtn: React.CSSProperties = { const downloadBtn: React.CSSProperties = {
position: 'absolute', bottom: 4, right: 4, position: 'absolute', bottom: 4, right: 4,
width: 22, height: 22, borderRadius: 4, width: 22, height: 22, borderRadius: 4,
background: 'rgba(0,0,0,0.6)', border: 'none', background: 'var(--color-shadow-modal)', border: 'none',
color: '#fff', fontSize: 12, cursor: 'pointer', color: '#fff', fontSize: 12, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
}; };
const refLabel: React.CSSProperties = { const refLabel: React.CSSProperties = {
fontSize: 10, color: '#888', marginTop: 4, overflow: 'hidden', fontSize: 10, color: 'var(--color-text-tertiary)', marginTop: 4, overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}; };
const playerWrap: React.CSSProperties = { const playerWrap: React.CSSProperties = {
position: 'relative', background: '#111118', borderRadius: 12, position: 'relative', background: 'var(--color-bg-modal)', borderRadius: 12,
padding: 24, border: '1px solid #2a2a38', padding: 24, border: '1px solid var(--color-border-modal)',
}; };
const playerClose: React.CSSProperties = { const playerClose: React.CSSProperties = {
position: 'absolute', top: 8, right: 12, position: 'absolute', top: 8, right: 12,
background: 'none', border: 'none', color: '#888', background: 'none', border: 'none', color: 'var(--color-text-tertiary)',
fontSize: 16, cursor: 'pointer', fontSize: 16, cursor: 'pointer',
}; };
const audioWrap: React.CSSProperties = { const audioWrap: React.CSSProperties = {
display: 'flex', flexDirection: 'column', alignItems: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center',
padding: '20px 40px', color: '#888', padding: '20px 40px', color: 'var(--color-text-tertiary)',
}; };

View File

@ -48,13 +48,16 @@
position: absolute; position: absolute;
top: calc(100% + 4px); top: calc(100% + 4px);
left: 0; left: 0;
background: #16161e; background: var(--color-bg-dropdown);
border: 1px solid var(--color-border-card); border: 1px solid var(--color-border-card);
border-radius: 8px; border-radius: 8px;
padding: 4px; padding: 4px;
z-index: 1000; z-index: 1000;
backdrop-filter: blur(20px) saturate(180%); backdrop-filter: var(--bf-glass-md);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); -webkit-backdrop-filter: var(--bf-glass-md);
box-shadow:
0 8px 32px var(--color-shadow-dropdown),
inset 0 1px 0 var(--color-inset-highlight);
opacity: 0; opacity: 0;
transform: translateY(-4px); transform: translateY(-4px);
pointer-events: none; pointer-events: none;
@ -76,15 +79,15 @@
padding: 7px 10px; padding: 7px 10px;
border-radius: 6px; border-radius: 6px;
font-size: 13px; font-size: 13px;
color: #b0b0c0; color: var(--color-text-monochrome);
cursor: pointer; cursor: pointer;
transition: all 0.12s; transition: all 0.12s;
white-space: nowrap; white-space: nowrap;
} }
.item:hover { .item:hover {
background: rgba(255, 255, 255, 0.06); background: var(--color-border-soft);
color: #fff; color: var(--color-text-primary);
} }
.item.selected { .item.selected {
@ -111,6 +114,6 @@
} }
.menu::-webkit-scrollbar-thumb { .menu::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1); background: var(--color-progress-track);
border-radius: 2px; border-radius: 2px;
} }

View File

@ -2,8 +2,14 @@
width: 76px; width: 76px;
height: 100%; height: 100%;
background: var(--color-sidebar-bg); background: var(--color-sidebar-bg);
backdrop-filter: blur(16px) saturate(160%); backdrop-filter: var(--bf-glass-md);
border-right: 1px solid rgba(255, 255, 255, 0.08); -webkit-backdrop-filter: var(--bf-glass-md);
border-right: 1px solid var(--color-border-modal-soft);
/* V2 玻璃顶边白高光 + 右侧立体阴影 */
box-shadow:
inset 0 1px 0 var(--color-inset-highlight),
1px 0 0 var(--color-border-soft),
2px 0 12px rgba(0, 0, 0, 0.04);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -44,7 +50,7 @@
.navItem:hover { .navItem:hover {
color: var(--color-text-secondary); color: var(--color-text-secondary);
background: rgba(255, 255, 255, 0.04); background: var(--color-bg-upload);
} }
.navItem.active { .navItem.active {
@ -76,7 +82,7 @@
} }
.quota:hover { .quota:hover {
background: rgba(255, 255, 255, 0.04); background: var(--color-bg-upload);
} }
.quotaNumber { .quotaNumber {
@ -95,6 +101,27 @@
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
/* Theme toggle (moon/sun) */
.themeToggle {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
transition: color 0.15s, background 0.15s;
padding: 0;
}
.themeToggle:hover {
color: var(--color-primary);
background: var(--color-bg-card);
}
/* Admin button */ /* Admin button */
.adminBtn { .adminBtn {
display: flex; display: flex;
@ -110,7 +137,7 @@
.adminBtn:hover { .adminBtn:hover {
color: var(--color-primary); color: var(--color-primary);
background: rgba(255, 255, 255, 0.06); background: var(--color-bg-card);
} }
/* User avatar */ /* User avatar */
@ -119,7 +146,7 @@
height: 34px; height: 34px;
border-radius: 50%; border-radius: 50%;
background: var(--color-primary); background: var(--color-primary);
color: #fff; color: var(--color-on-primary);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View File

@ -1,5 +1,6 @@
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import { useThemeStore } from '../store/theme';
import logoImg from '../assets/logo_32.png'; import logoImg from '../assets/logo_32.png';
import styles from './Sidebar.module.css'; import styles from './Sidebar.module.css';
@ -8,6 +9,8 @@ export function Sidebar() {
const location = useLocation(); const location = useLocation();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const quota = useAuthStore((s) => s.quota); const quota = useAuthStore((s) => s.quota);
const theme = useThemeStore((s) => s.theme);
const toggleTheme = useThemeStore((s) => s.toggleTheme);
const isActive = (path: string) => location.pathname === path; const isActive = (path: string) => location.pathname === path;
const role = user?.role; const role = user?.role;
@ -85,6 +88,25 @@ export function Sidebar() {
</div> </div>
)} )}
{/* Theme toggle (moon in dark mode → switch to light; sun in light mode → switch to dark) */}
<button
className={styles.themeToggle}
onClick={toggleTheme}
title={theme === 'dark' ? '切换到浅色主题' : '切换到深色主题'}
aria-label={theme === 'dark' ? '切换到浅色主题' : '切换到深色主题'}
>
{theme === 'dark' ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
)}
</button>
{/* Admin entry - super admin only */} {/* Admin entry - super admin only */}
{role === 'super_admin' && ( {role === 'super_admin' && (
<div <div

View File

@ -3,15 +3,15 @@
top: 20px; top: 20px;
left: 50%; left: 50%;
transform: translateX(-50%) translateY(-20px); transform: translateX(-50%) translateY(-20px);
background: rgba(255, 255, 255, 0.06); background: var(--color-bg-glass-strong);
backdrop-filter: blur(24px) saturate(180%); backdrop-filter: var(--bf-glass-md);
-webkit-backdrop-filter: blur(24px) saturate(180%); -webkit-backdrop-filter: var(--bf-glass-md);
border: 1px solid rgba(255, 255, 255, 0.10); border: 1px solid var(--color-border-card);
box-shadow: box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.05) inset, 0 0 0 1px var(--color-inset-highlight) inset,
0 8px 32px rgba(0, 0, 0, 0.4), 0 8px 32px var(--color-shadow-dropdown),
0 1px 0 rgba(255, 255, 255, 0.12) inset; inset 0 1px 0 var(--color-inset-highlight-strong);
color: #fff; color: var(--color-text-primary);
padding: 10px 24px; padding: 10px 24px;
border-radius: 10px; border-radius: 10px;
font-size: 13px; font-size: 13px;
@ -31,8 +31,8 @@
width: 18px; width: 18px;
height: 18px; height: 18px;
border-radius: 50%; border-radius: 50%;
background: #e8952e; background: var(--color-warning);
color: #fff; color: var(--color-on-primary);
font-size: 12px; font-size: 12px;
font-weight: bold; font-weight: bold;
flex-shrink: 0; flex-shrink: 0;

View File

@ -24,7 +24,7 @@
.btn:hover { .btn:hover {
background: var(--color-bg-hover); background: var(--color-bg-hover);
color: #b0b0c0; color: var(--color-text-light);
} }
.btn.primary { .btn.primary {
@ -32,7 +32,7 @@
} }
.btn.primary:hover { .btn.primary:hover {
color: #33ccf0; color: var(--color-info-hover-2);
} }
.spacer { .spacer {
@ -59,12 +59,12 @@
.sendEnabled { .sendEnabled {
background: var(--color-btn-send-active); background: var(--color-btn-send-active);
box-shadow: 0 2px 12px rgba(0, 184, 230, 0.3); box-shadow: 0 2px 12px var(--color-info-shadow-soft);
} }
.sendEnabled:hover { .sendEnabled:hover {
background: #00ccff; background: var(--color-info-hover);
box-shadow: 0 4px 20px rgba(0, 184, 230, 0.5); box-shadow: 0 4px 20px var(--color-info-shadow-strong);
} }
.label { .label {

View File

@ -311,7 +311,7 @@ export function Toolbar() {
{isSubmittable && ( {isSubmittable && (
<span <span
onClick={() => useInputBarStore.getState().reset()} onClick={() => useInputBarStore.getState().reset()}
style={{ fontSize: 12, color: '#8b8ea8', whiteSpace: 'nowrap', userSelect: 'none', cursor: 'pointer', transition: 'filter 0.15s', marginRight: 20, lineHeight: 1 }} style={{ fontSize: 12, color: 'var(--color-text-secondary)', whiteSpace: 'nowrap', userSelect: 'none', cursor: 'pointer', transition: 'filter 0.15s', marginRight: 20, lineHeight: 1 }}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.filter = 'brightness(1.4)'; }} onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.filter = 'brightness(1.4)'; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.filter = ''; }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.filter = ''; }}
> >
@ -322,7 +322,7 @@ export function Toolbar() {
{/* Estimated cost */} {/* Estimated cost */}
{isSubmittable && (team?.token_price || 0) > 0 && ( {isSubmittable && (team?.token_price || 0) > 0 && (
<span <span
style={{ fontSize: 12, color: '#8b8ea8', whiteSpace: 'nowrap', userSelect: 'none', marginRight: 16, lineHeight: 1 }} style={{ fontSize: 12, color: 'var(--color-text-secondary)', whiteSpace: 'nowrap', userSelect: 'none', marginRight: 16, lineHeight: 1 }}
title={`预估公式: (宽 ×× 24fps × 时长) / 1024 = tokens, tokens × 单价 / 1000000 = 费用\n⚠ 仅为预估值,实际费用以火山 API 返回的 token 数为准`} title={`预估公式: (宽 ×× 24fps × 时长) / 1024 = tokens, tokens × 单价 / 1000000 = 费用\n⚠ 仅为预估值,实际费用以火山 API 返回的 token 数为准`}
> >
{estimatedTokens.toLocaleString()} tokens / ¥{estimatedCost} {estimatedTokens.toLocaleString()} tokens / ¥{estimatedCost}
@ -334,7 +334,7 @@ export function Toolbar() {
className={`${styles.sendBtn} ${isSubmittable ? styles.sendEnabled : styles.sendDisabled}`} className={`${styles.sendBtn} ${isSubmittable ? styles.sendEnabled : styles.sendDisabled}`}
onClick={handleSend} onClick={handleSend}
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--color-on-primary)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="19" x2="12" y2="5" /> <line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" /> <polyline points="5 12 12 5 19 12" />
</svg> </svg>

View File

@ -19,8 +19,8 @@
.trigger { .trigger {
height: var(--thumbnail-size); height: var(--thumbnail-size);
aspect-ratio: 3 / 4; aspect-ratio: 3 / 4;
border: 1.5px dashed #3a3a48; border: 1.5px dashed var(--color-border-modal);
background: rgba(255, 255, 255, 0.03); background: var(--color-bg-upload);
border-radius: var(--radius-btn); border-radius: var(--radius-btn);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -33,8 +33,8 @@
} }
.trigger:hover { .trigger:hover {
border-color: #5a5a6a; border-color: var(--color-border-modal-hover);
background: rgba(255, 255, 255, 0.06); background: var(--color-bg-card);
} }
.triggerText { .triggerText {
@ -60,13 +60,13 @@
/* Add-more button gets opaque background when expanded (overlays prompt text) */ /* Add-more button gets opaque background when expanded (overlays prompt text) */
.thumbRowExpanded .addMore { .thumbRowExpanded .addMore {
background: #16161e; background: var(--color-bg-modal-elevated);
border-color: #3a3a48; border-color: var(--color-border-modal);
} }
.thumbRowExpanded .addMore:hover { .thumbRowExpanded .addMore:hover {
background: #1e1e2a; background: var(--color-bg-modal-hover);
border-color: #5a5a6a; border-color: var(--color-border-modal-hover);
} }
/* Each thumbnail card — 3:4 portrait ratio, overflow visible for tooltip */ /* Each thumbnail card — 3:4 portrait ratio, overflow visible for tooltip */
@ -86,13 +86,13 @@
height: 100%; height: 100%;
border-radius: var(--radius-thumbnail); border-radius: var(--radius-thumbnail);
overflow: hidden; overflow: hidden;
background: #1a1a24; background: var(--color-bg-dropdown-elevated);
border: 1.5px solid #2a2a38; border: 1.5px solid var(--color-border-modal);
position: relative; position: relative;
} }
.thumbItem:hover .thumbInner { .thumbItem:hover .thumbInner {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 12px var(--color-shadow-thumb);
} }
.thumbMedia { .thumbMedia {
@ -109,7 +109,7 @@
right: 4px; right: 4px;
width: 18px; width: 18px;
height: 18px; height: 18px;
background: rgba(0, 0, 0, 0.7); background: var(--color-overlay-strong);
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
@ -137,8 +137,8 @@
white-space: nowrap; white-space: nowrap;
padding: 4px 10px; padding: 4px 10px;
border-radius: 6px; border-radius: 6px;
background: rgba(13, 13, 26, 0.92); background: var(--color-bg-dropdown);
border: 1px solid rgba(255, 255, 255, 0.10); border: 1px solid var(--color-border-card);
color: var(--color-text-primary); color: var(--color-text-primary);
font-size: 12px; font-size: 12px;
pointer-events: none; pointer-events: none;
@ -160,8 +160,8 @@
padding: 2px 4px; padding: 2px 4px;
text-align: center; text-align: center;
font-size: 10px; font-size: 10px;
color: #fff; color: var(--color-on-overlay);
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); background: linear-gradient(transparent, var(--color-overlay-strong));
opacity: 0; opacity: 0;
transition: opacity 0.25s; transition: opacity 0.25s;
white-space: nowrap; white-space: nowrap;
@ -179,8 +179,8 @@
position: relative; position: relative;
height: var(--thumbnail-size); height: var(--thumbnail-size);
aspect-ratio: 3 / 4; aspect-ratio: 3 / 4;
border: 1.5px dashed #3a3a48; border: 1.5px dashed var(--color-border-modal);
background: rgba(255, 255, 255, 0.03); background: var(--color-bg-upload);
border-radius: var(--radius-btn); border-radius: var(--radius-btn);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -198,8 +198,8 @@
} }
.addMore:hover { .addMore:hover {
border-color: #5a5a6a; border-color: var(--color-border-modal-hover);
background: rgba(255, 255, 255, 0.06); background: var(--color-bg-card);
} }
.addMoreVisible { .addMoreVisible {
@ -216,8 +216,8 @@
white-space: nowrap; white-space: nowrap;
padding: 4px 10px; padding: 4px 10px;
border-radius: 6px; border-radius: 6px;
background: rgba(13, 13, 26, 0.92); background: var(--color-bg-dropdown);
border: 1px solid rgba(255, 255, 255, 0.10); border: 1px solid var(--color-border-card);
color: var(--color-text-primary); color: var(--color-text-primary);
font-size: 12px; font-size: 12px;
pointer-events: none; pointer-events: none;
@ -237,10 +237,10 @@
width: 28px; width: 28px;
height: 28px; height: 28px;
border-radius: 50%; border-radius: 50%;
background: rgba(255, 255, 255, 0.15); background: var(--color-bg-on-media);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid var(--color-progress-track);
color: #fff; color: var(--color-on-overlay);
font-size: 16px; font-size: 16px;
font-weight: 400; font-weight: 400;
display: flex; display: flex;
@ -252,7 +252,7 @@
} }
.countBadge:hover { .countBadge:hover {
background: rgba(255, 255, 255, 0.25); background: var(--color-bg-on-media-hover);
} }
/* Tooltip for "+" badge */ /* Tooltip for "+" badge */
@ -263,8 +263,8 @@
white-space: nowrap; white-space: nowrap;
padding: 4px 10px; padding: 4px 10px;
border-radius: 6px; border-radius: 6px;
background: rgba(13, 13, 26, 0.92); background: var(--color-bg-dropdown);
border: 1px solid rgba(255, 255, 255, 0.10); border: 1px solid var(--color-border-card);
color: var(--color-text-primary); color: var(--color-text-primary);
font-size: 12px; font-size: 12px;
pointer-events: none; pointer-events: none;
@ -278,7 +278,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #1a1a24; background: var(--color-bg-dropdown-elevated);
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
@ -289,13 +289,13 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(0, 0, 0, 0.5); background: var(--color-overlay-soft);
border-radius: var(--radius-thumbnail); border-radius: var(--radius-thumbnail);
z-index: 2; z-index: 2;
} }
.uploadError { .uploadError {
background: rgba(239, 68, 68, 0.25); background: var(--color-danger-bg);
cursor: pointer; cursor: pointer;
} }

View File

@ -5,7 +5,7 @@
bottom: 0; bottom: 0;
left: 0; left: 0;
z-index: 200; z-index: 200;
background: #07070f; background: var(--color-bg-page);
display: flex; display: flex;
overflow: hidden; overflow: hidden;
animation: overlayIn 0.2s ease-out; animation: overlayIn 0.2s ease-out;
@ -44,9 +44,9 @@
z-index: 10; z-index: 10;
width: 36px; width: 36px;
height: 36px; height: 36px;
background: rgba(255, 255, 255, 0.06); background: var(--color-bg-card);
border: none; border: none;
color: rgba(255, 255, 255, 0.5); color: var(--color-text-on-glass-soft);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -56,8 +56,8 @@
} }
.closeBtn:hover { .closeBtn:hover {
color: #fff; color: var(--color-text-primary);
background: rgba(255, 255, 255, 0.12); background: var(--color-bg-glass-hover);
} }
.floatingActions { .floatingActions {
@ -74,7 +74,7 @@
width: 36px; width: 36px;
height: 36px; height: 36px;
border-radius: 50%; border-radius: 50%;
background: rgba(255, 255, 255, 0.08); background: var(--color-bg-hover);
border: none; border: none;
color: rgba(255, 255, 255, 0.6); color: rgba(255, 255, 255, 0.6);
display: flex; display: flex;
@ -85,8 +85,8 @@
} }
.floatingBtn:hover { .floatingBtn:hover {
color: #fff; color: var(--color-text-primary);
background: rgba(255, 255, 255, 0.15); background: var(--color-bg-glass-hover);
} }
/* Video area — centres the player */ /* Video area — centres the player */
@ -107,7 +107,7 @@
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
border-radius: 16px; border-radius: 16px;
background: #000; background: var(--color-bg-video);
position: relative; position: relative;
} }
@ -126,6 +126,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8)); background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
/* Note: gradient stops keep raw rgba — no exact var match for 0.8 black */
border-radius: 0 0 16px 16px; border-radius: 0 0 16px 16px;
padding: 24px 0 0; padding: 24px 0 0;
opacity: 0; opacity: 0;
@ -140,7 +141,7 @@
.progressTrack { .progressTrack {
width: 100%; width: 100%;
height: 4px; height: 4px;
background: rgba(255, 255, 255, 0.2); background: var(--color-progress-track);
cursor: pointer; cursor: pointer;
position: relative; position: relative;
flex-shrink: 0; flex-shrink: 0;
@ -172,7 +173,7 @@
height: 32px; height: 32px;
border: none; border: none;
background: none; background: none;
color: #fff; color: var(--color-on-overlay);
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
border-radius: 4px; border-radius: 4px;
@ -180,7 +181,7 @@
} }
.controlBtn:hover { .controlBtn:hover {
background: rgba(255, 255, 255, 0.1); background: var(--color-border-card);
} }
.timeDisplay { .timeDisplay {
@ -208,7 +209,7 @@
height: 4px; height: 4px;
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
background: rgba(255, 255, 255, 0.2); background: var(--color-progress-track);
border-radius: 2px; border-radius: 2px;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
@ -219,7 +220,7 @@
width: 12px; width: 12px;
height: 12px; height: 12px;
border-radius: 50%; border-radius: 50%;
background: #fff; background: var(--color-on-overlay);
cursor: pointer; cursor: pointer;
} }
@ -227,7 +228,7 @@
width: 12px; width: 12px;
height: 12px; height: 12px;
border-radius: 50%; border-radius: 50%;
background: #fff; background: var(--color-on-overlay);
border: none; border: none;
cursor: pointer; cursor: pointer;
} }
@ -251,16 +252,16 @@
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: 6px; border-radius: 6px;
background: rgba(255, 255, 255, 0.1); background: var(--color-border-card);
border: none; border: none;
color: rgba(255, 255, 255, 0.7); color: var(--color-text-on-glass);
cursor: pointer; cursor: pointer;
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s;
} }
.navArrowBtn:hover:not(.navArrowDisabled) { .navArrowBtn:hover:not(.navArrowDisabled) {
background: rgba(255, 255, 255, 0.2); background: var(--color-progress-track);
color: #fff; color: var(--color-on-overlay);
} }
.navArrowDisabled { .navArrowDisabled {
@ -276,10 +277,11 @@
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-left: 1px solid rgba(255, 255, 255, 0.08); border-left: 1px solid var(--color-border-modal-soft);
background: rgba(255, 255, 255, 0.04); background: var(--color-bg-glass);
backdrop-filter: blur(24px) saturate(180%); backdrop-filter: var(--bf-glass-lg);
-webkit-backdrop-filter: blur(24px) saturate(180%); -webkit-backdrop-filter: var(--bf-glass-lg);
box-shadow: inset 0 1px 0 var(--color-inset-highlight);
} }
/* Header with download + icons */ /* Header with download + icons */
@ -288,7 +290,7 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 20px 24px; padding: 20px 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06); border-bottom: 1px solid var(--color-border-soft);
} }
.headerIcons { .headerIcons {
@ -306,14 +308,14 @@
border-radius: 8px; border-radius: 8px;
background: none; background: none;
border: none; border: none;
color: rgba(255, 255, 255, 0.5); color: var(--color-text-on-glass-soft);
cursor: pointer; cursor: pointer;
transition: color 0.15s, background 0.15s; transition: color 0.15s, background 0.15s;
} }
.iconBtn:hover { .iconBtn:hover {
color: rgba(255, 255, 255, 0.85); color: var(--color-text-primary);
background: rgba(255, 255, 255, 0.06); background: var(--color-bg-glass-hover);
} }
/* More menu dropdown */ /* More menu dropdown */
@ -327,12 +329,12 @@
right: 0; right: 0;
margin-top: 4px; margin-top: 4px;
min-width: 120px; min-width: 120px;
background: #1a1a24; background: var(--color-bg-dropdown-elevated);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid var(--color-border-modal-soft);
border-radius: 8px; border-radius: 8px;
padding: 4px; padding: 4px;
z-index: 20; z-index: 20;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); box-shadow: 0 8px 24px var(--color-shadow-dropdown);
} }
.moreDropdownItem { .moreDropdownItem {
@ -343,7 +345,7 @@
padding: 8px 12px; padding: 8px 12px;
border: none; border: none;
background: none; background: none;
color: #ef4444; color: var(--color-danger-text);
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
border-radius: 6px; border-radius: 6px;
@ -352,7 +354,7 @@
} }
.moreDropdownItem:hover { .moreDropdownItem:hover {
background: rgba(255, 255, 255, 0.06); background: var(--color-bg-card);
} }
.downloadBtn { .downloadBtn {
@ -362,7 +364,7 @@
padding: 8px 24px; padding: 8px 24px;
border-radius: 10px; border-radius: 10px;
background: var(--color-primary); background: var(--color-primary);
color: #fff; color: var(--color-on-primary);
border: none; border: none;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
@ -391,13 +393,13 @@
.sectionLabel { .sectionLabel {
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
color: #8b8ea8; color: var(--color-text-secondary);
margin-bottom: 10px; margin-bottom: 10px;
} }
.promptText { .promptText {
font-size: 14px; font-size: 14px;
color: #f1f0ff; color: var(--color-text-primary);
line-height: 1.7; line-height: 1.7;
word-break: break-word; word-break: break-word;
} }
@ -425,32 +427,32 @@
height: 56px; height: 56px;
border-radius: 6px; border-radius: 6px;
object-fit: cover; object-fit: cover;
background: rgba(255, 255, 255, 0.04); background: var(--color-bg-upload);
border: 1px solid rgba(255, 255, 255, 0.06); border: 1px solid var(--color-border-soft);
} }
.refAudioPlaceholder { .refAudioPlaceholder {
width: 56px; width: 56px;
height: 56px; height: 56px;
border-radius: 6px; border-radius: 6px;
background: rgba(255, 255, 255, 0.04); background: var(--color-bg-upload);
border: 1px solid rgba(255, 255, 255, 0.06); border: 1px solid var(--color-border-soft);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #8b8ea8; color: var(--color-text-secondary);
} }
.refLabel { .refLabel {
font-size: 10px; font-size: 10px;
color: #8b8ea8; color: var(--color-text-secondary);
} }
/* ── Fixed bottom section ── */ /* ── Fixed bottom section ── */
.infoPanelBottom { .infoPanelBottom {
flex-shrink: 0; flex-shrink: 0;
padding: 16px 24px 24px; padding: 16px 24px 24px;
border-top: 1px solid rgba(255, 255, 255, 0.06); border-top: 1px solid var(--color-border-soft);
} }
/* Compact info bar — single-line meta */ /* Compact info bar — single-line meta */
@ -461,10 +463,10 @@
gap: 8px; gap: 8px;
padding: 12px 16px; padding: 12px 16px;
border-radius: 10px; border-radius: 10px;
background: rgba(255, 255, 255, 0.04); background: var(--color-bg-upload);
border: 1px solid rgba(255, 255, 255, 0.06); border: 1px solid var(--color-border-soft);
font-size: 13px; font-size: 13px;
color: #8b8ea8; color: var(--color-text-secondary);
margin-bottom: 12px; margin-bottom: 12px;
} }
@ -472,7 +474,7 @@
width: 3px; width: 3px;
height: 3px; height: 3px;
border-radius: 50%; border-radius: 50%;
background: rgba(255, 255, 255, 0.2); background: var(--color-progress-track);
flex-shrink: 0; flex-shrink: 0;
} }
@ -488,11 +490,11 @@
gap: 6px; gap: 6px;
flex: 1; flex: 1;
padding: 10px 0; padding: 10px 0;
background: rgba(255, 255, 255, 0.06); background: var(--color-bg-card);
border: 1px solid rgba(255, 255, 255, 0.10); border: 1px solid var(--color-border-card);
border-radius: 10px; border-radius: 10px;
font-size: 13px; font-size: 13px;
color: #8b8ea8; color: var(--color-text-secondary);
cursor: pointer; cursor: pointer;
transition: color 0.15s, background 0.15s; transition: color 0.15s, background 0.15s;
font-family: inherit; font-family: inherit;
@ -500,8 +502,8 @@
} }
.cardBtn:hover { .cardBtn:hover {
color: #f1f0ff; color: var(--color-text-primary);
background: rgba(255, 255, 255, 0.10); background: var(--color-border-card);
} }
/* /*
@ -523,6 +525,6 @@
flex: 1; flex: 1;
width: 100%; width: 100%;
border-left: none; border-left: none;
border-top: 1px solid rgba(255, 255, 255, 0.06); border-top: 1px solid var(--color-border-soft);
} }
} }

View File

@ -503,7 +503,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
)} )}
{ref.previewUrl && ( {ref.previewUrl && (
<a href={ref.previewUrl} download={ref.label} target="_blank" rel="noopener noreferrer" <a href={ref.previewUrl} download={ref.label} target="_blank" rel="noopener noreferrer"
style={{ position: 'absolute', bottom: 2, right: 2, width: 18, height: 18, borderRadius: 3, background: 'rgba(0,0,0,0.6)', color: '#fff', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', textDecoration: 'none' }} style={{ position: 'absolute', bottom: 2, right: 2, width: 18, height: 18, borderRadius: 3, background: 'var(--color-shadow-modal)', color: 'var(--color-on-overlay)', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', textDecoration: 'none' }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
></a> ></a>
)} )}
@ -568,13 +568,13 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
/> />
<ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} /> <ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />
{refMediaPreview && ( {refMediaPreview && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10002 }} onClick={() => setRefMediaPreview(null)}> <div style={{ position: 'fixed', inset: 0, background: 'var(--color-overlay-strong)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10002 }} onClick={() => setRefMediaPreview(null)}>
<div style={{ position: 'relative', background: '#111118', borderRadius: 12, padding: 24, border: '1px solid #2a2a38' }} onClick={(e) => e.stopPropagation()}> <div style={{ position: 'relative', background: 'var(--color-bg-modal)', borderRadius: 12, padding: 24, border: '1px solid var(--color-border-modal)' }} onClick={(e) => e.stopPropagation()}>
<button style={{ position: 'absolute', top: 8, right: 12, background: 'none', border: 'none', color: '#888', fontSize: 16, cursor: 'pointer' }} onClick={() => setRefMediaPreview(null)}></button> <button style={{ position: 'absolute', top: 8, right: 12, background: 'none', border: 'none', color: 'var(--color-text-tertiary)', fontSize: 16, cursor: 'pointer' }} onClick={() => setRefMediaPreview(null)}></button>
{refMediaPreview.type === 'video' ? ( {refMediaPreview.type === 'video' ? (
<video src={rewriteTosUrl(refMediaPreview.url)} controls autoPlay style={{ maxWidth: '80vw', maxHeight: '70vh', borderRadius: 8 }} /> <video src={rewriteTosUrl(refMediaPreview.url)} controls autoPlay style={{ maxWidth: '80vw', maxHeight: '70vh', borderRadius: 8 }} />
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '20px 40px', color: '#888' }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '20px 40px', color: 'var(--color-text-tertiary)' }}>
<div style={{ fontSize: 48, marginBottom: 16 }}></div> <div style={{ fontSize: 48, marginBottom: 16 }}></div>
<audio src={rewriteTosUrl(refMediaPreview.url)} controls autoPlay style={{ width: 320 }} /> <audio src={rewriteTosUrl(refMediaPreview.url)} controls autoPlay style={{ width: 320 }} />
</div> </div>

View File

@ -129,7 +129,7 @@ export function VideoGenerationPage() {
height: '100%', flexDirection: 'column', gap: 16, height: '100%', flexDirection: 'column', gap: 16,
color: 'var(--color-text-secondary)', color: 'var(--color-text-secondary)',
}}> }}>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#8b8ea8" strokeWidth="1.5"> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" strokeWidth="1.5">
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
<path d="M4.93 4.93l14.14 14.14" /> <path d="M4.93 4.93l14.14 14.14" />
</svg> </svg>
@ -151,7 +151,7 @@ export function VideoGenerationPage() {
onClick={() => setShowAnnouncement(true)} onClick={() => setShowAnnouncement(true)}
style={{ style={{
position: 'absolute', top: 12, right: 16, zIndex: 20, position: 'absolute', top: 12, right: 16, zIndex: 20,
background: 'rgba(255,255,255,0.06)', border: '1px solid var(--color-border-card)', background: 'var(--color-bg-card)', border: '1px solid var(--color-border-card)',
borderRadius: '50%', width: 32, height: 32, borderRadius: '50%', width: 32, height: 32,
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--color-text-secondary)', cursor: 'pointer', color: 'var(--color-text-secondary)',
@ -193,17 +193,17 @@ export function VideoGenerationPage() {
onClick={() => scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })} onClick={() => scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })}
style={{ style={{
marginLeft: 'auto', marginLeft: 'auto',
background: 'rgba(255, 255, 255, 0.06)', background: 'var(--color-bg-card)',
backdropFilter: 'blur(24px) saturate(180%)', backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)', WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: '1px solid rgba(255, 255, 255, 0.10)', border: '1px solid var(--color-border-card)',
boxShadow: '0 0 0 1px rgba(255,255,255,0.05) inset, 0 4px 16px rgba(0,0,0,0.3)', boxShadow: '0 0 0 1px var(--color-inset-highlight) inset, 0 4px 16px var(--color-overlay-medium)',
borderRadius: 6, padding: '4px 12px', fontSize: 12, borderRadius: 6, padding: '4px 12px', fontSize: 12,
color: 'var(--color-text-secondary)', cursor: 'pointer', color: 'var(--color-text-secondary)', cursor: 'pointer',
transition: 'all 0.15s', whiteSpace: 'nowrap', transition: 'all 0.15s', whiteSpace: 'nowrap',
}} }}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,0.10)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-text-primary)'; }} onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--color-bg-hover)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-text-primary)'; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = 'rgba(255,255,255,0.06)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-text-secondary)'; }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--color-bg-card)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-text-secondary)'; }}
> >
</button> </button>

View File

@ -1,6 +1,31 @@
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap');
/*
THEME TOKENS
:root layout / sizing (theme-agnostic) + default DARK colors
[data-theme="light"] light overrides
切换由 web/src/store/theme.ts 写到 <html data-theme="dark|light">
*/
:root { :root {
/* ── Layout / sizing (theme-agnostic) ── */
--radius-card: 12px;
--sidebar-width: 240px;
--sidebar-collapsed-width: 64px;
--radius-input-bar: 20px;
--radius-btn: 8px;
--radius-send-btn: 50%;
--radius-thumbnail: 8px;
--radius-dropdown: 12px;
--input-bar-max-width: 950px;
--send-btn-size: 36px;
--thumbnail-size: 80px;
--toolbar-height: 44px;
--toolbar-btn-height: 32px;
/*
DEFAULT = DARK THEME COLORS
*/
--color-bg-page: #07070f; --color-bg-page: #07070f;
--color-bg-input-bar: rgba(255, 255, 255, 0.06); --color-bg-input-bar: rgba(255, 255, 255, 0.06);
--color-border-input-bar: rgba(255, 255, 255, 0.10); --color-border-input-bar: rgba(255, 255, 255, 0.10);
@ -16,7 +41,6 @@
--color-btn-send-active: #6c63ff; --color-btn-send-active: #6c63ff;
--color-sidebar-bg: rgba(7, 7, 15, 0.80); --color-sidebar-bg: rgba(7, 7, 15, 0.80);
/* Phase 3: Admin theme tokens */
--color-bg-sidebar: rgba(7, 7, 15, 0.80); --color-bg-sidebar: rgba(7, 7, 15, 0.80);
--color-sidebar-active: rgba(255, 255, 255, 0.08); --color-sidebar-active: rgba(255, 255, 255, 0.08);
--color-sidebar-hover: rgba(255, 255, 255, 0.04); --color-sidebar-hover: rgba(255, 255, 255, 0.04);
@ -25,23 +49,371 @@
--color-success: #00b894; --color-success: #00b894;
--color-danger: #e74c3c; --color-danger: #e74c3c;
--color-warning: #f39c12; --color-warning: #f39c12;
--radius-card: 12px;
--sidebar-width: 240px;
--sidebar-collapsed-width: 64px;
--radius-input-bar: 20px; /* Modal & overlay */
--radius-btn: 8px; --color-modal-overlay: rgba(0, 0, 0, 0.6);
--radius-send-btn: 50%; --color-overlay-strong: rgba(0, 0, 0, 0.7);
--radius-thumbnail: 8px; --color-bg-modal: #111118;
--radius-dropdown: 12px; --color-bg-modal-elevated: #16161e;
--color-bg-modal-glass: rgba(22, 22, 30, 0.92);
--color-bg-elevated: #0a0a0f;
--color-bg-placeholder: #1a1a2e;
--color-bg-dropdown-elevated: #1a1a24;
--color-bg-video: #000;
--color-border-modal: #2a2a38;
--color-border-modal-soft: rgba(255, 255, 255, 0.08);
--color-border-soft: rgba(255, 255, 255, 0.06);
--color-border-row: rgba(255, 255, 255, 0.04);
--color-shadow-modal: rgba(0, 0, 0, 0.6);
--color-shadow-dropdown: rgba(0, 0, 0, 0.4);
--input-bar-max-width: 950px; /* Text variants */
--send-btn-size: 36px; --color-text-tertiary: #888;
--thumbnail-size: 80px; --color-text-quaternary: #555;
--toolbar-height: 44px; --color-text-light: #e2e2ea;
--toolbar-btn-height: 32px; --color-text-monochrome: #ccc;
--color-text-on-glass: rgba(255, 255, 255, 0.7);
--color-text-on-glass-soft: rgba(255, 255, 255, 0.5);
--color-text-on-glass-faint: rgba(255, 255, 255, 0.4);
/* Status accents */
--color-info: #00b8e6;
--color-purple-accent: #a78bfa;
--color-danger-text: #ef4444;
--color-success-bg: rgba(0, 184, 148, 0.15);
--color-success-bg-hover: rgba(0, 184, 148, 0.10);
--color-info-bg: rgba(0, 184, 230, 0.15);
--color-info-bg-hover: rgba(0, 184, 230, 0.10);
--color-info-bg-soft: rgba(0, 184, 230, 0.12);
--color-danger-bg: rgba(231, 76, 60, 0.15);
--color-danger-bg-hover: rgba(231, 76, 60, 0.10);
--color-danger-bg-soft: rgba(231, 76, 60, 0.08);
--color-danger-border: rgba(231, 76, 60, 0.20);
--color-purple-bg: rgba(167, 139, 250, 0.15);
--color-purple-bg-hover: rgba(167, 139, 250, 0.10);
/* Charts */
--color-tooltip-bg: rgba(13, 13, 26, 0.95);
--color-tooltip-border: rgba(255, 255, 255, 0.10);
--color-chart-axis: rgba(255, 255, 255, 0.08);
--color-chart-grid: rgba(255, 255, 255, 0.06);
--color-chart-area-from: rgba(108, 99, 255, 0.25);
--color-chart-area-to: rgba(108, 99, 255, 0.02);
--color-accent-2: #06d6a0;
--color-primary-2: #8b5cf6;
/* Misc */
--color-progress-track: rgba(255, 255, 255, 0.2);
--color-on-primary: #fff;
--color-on-overlay: #fff;
/* Brand mint accent (Auth modals) */
--color-mint-accent: #7edcc8;
--color-mint-accent-bg: rgba(120, 220, 200, 0.12);
--color-mint-accent-bg-hover: rgba(120, 220, 200, 0.22);
--color-mint-accent-border: rgba(120, 220, 200, 0.30);
--color-mint-accent-glow: rgba(120, 220, 200, 0.15);
/* Warning bg variants */
--color-warning-bg: rgba(243, 156, 18, 0.12);
--color-warning-bg-hover: rgba(243, 156, 18, 0.18);
--color-warning-border: rgba(243, 156, 18, 0.30);
/* Primary alpha hover */
--color-primary-bg: rgba(108, 99, 255, 0.04);
--color-primary-bg-hover: rgba(108, 99, 255, 0.08);
/* Generic overlay tiers */
--color-overlay-soft: rgba(0, 0, 0, 0.5);
--color-overlay-medium: rgba(0, 0, 0, 0.3);
--color-overlay-faint: rgba(0, 0, 0, 0.15);
/* Modal/upload trigger hover surfaces */
--color-border-modal-hover: #5a5a6a;
--color-bg-modal-hover: #1e1e2a;
/* Inset highlight (glass surfaces) */
--color-inset-highlight: rgba(255, 255, 255, 0.05);
--color-inset-highlight-strong: rgba(255, 255, 255, 0.12);
/* @ Mention pill */
--color-mention-bg: rgba(108, 99, 255, 0.12);
--color-mention-bg-hover: rgba(108, 99, 255, 0.22);
--color-mention-bg-active: rgba(108, 99, 255, 0.15);
--color-mention-text: rgba(108, 99, 255, 0.7);
--color-mention-text-hover: rgba(108, 99, 255, 0.9);
/* Shimmer */
--color-shimmer-purple-soft: rgba(108, 99, 255, 0.03);
--color-shimmer-purple-mid: rgba(108, 99, 255, 0.08);
--color-shimmer-purple-2-mid: rgba(139, 92, 246, 0.12);
/* Brighter danger */
--color-danger-hover: #ff6b6b;
--color-danger-hover-bg: rgba(255, 107, 107, 0.08);
--color-danger-hover-bg-strong: rgba(255, 107, 107, 0.10);
--color-danger-hover-border: rgba(255, 107, 107, 0.30);
/* Info hover (brighter cyan) */
--color-info-hover: #00ccff;
--color-info-hover-2: #33ccf0;
--color-info-shadow-soft: rgba(0, 184, 230, 0.30);
--color-info-shadow-strong: rgba(0, 184, 230, 0.50);
/* Lightbox / extra-deep overlay */
--color-overlay-deep: rgba(0, 0, 0, 0.85);
/* White alpha utility */
--color-bg-on-media: rgba(255, 255, 255, 0.15);
--color-bg-on-media-hover: rgba(255, 255, 255, 0.25);
/* Toast warning */
--color-warning-toast: #e8952e;
/* Scrollbar thumb */
--color-scrollbar-thumb: rgba(255, 255, 255, 0.10);
--color-scrollbar-thumb-hover: rgba(255, 255, 255, 0.20);
/* Aurora / decorative bg layers */
--color-aurora-1: rgba(108, 99, 255, 0.6);
--color-aurora-2: rgba(59, 130, 246, 0.5);
--color-aurora-3: rgba(139, 92, 246, 0.35);
--color-aurora-peach: rgba(255, 130, 100, 0.20);
--color-cursor-glow: rgba(108, 99, 255, 0.06);
--color-grid-line: rgba(255, 255, 255, 0.02);
/* ─── V2 新增 tokens (2026-05-11) ─── */
/* GLASS 类(透明白 + blur用于 sidebar/banner/modal overlay/dropdown */
--color-bg-glass: rgba(255, 255, 255, 0.06);
--color-bg-glass-strong: rgba(255, 255, 255, 0.10);
--color-border-glass-edge: rgba(255, 255, 255, 0.12);
/* 行 hover实体卡内表格 hover与 --color-bg-hover 区分) */
--color-bg-row-hover: rgba(255, 255, 255, 0.02);
/* Multi-layer shadows — Vercel/GitBook 风格 */
--shadow-card-light: 0 1px 2px rgba(0, 0, 0, 0.30),
0 4px 16px rgba(0, 0, 0, 0.40);
--shadow-glass-light: 0 8px 32px rgba(0, 0, 0, 0.30),
0 1px 2px rgba(0, 0, 0, 0.40),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
/* 暖调 chip (公告/Trial/"新版上线") */
--color-chip-warm-bg: rgba(255, 200, 130, 0.10);
--color-chip-warm-border: rgba(255, 200, 130, 0.25);
--color-chip-warm-text: #f1f0ff;
--color-chip-warm-badge-bg: rgba(255, 150, 100, 0.20);
--color-chip-warm-badge-text: #ffb589;
/* V2-fix: 主题感知的 hover/border (替换 V1 漏改的白透 alpha) */
--color-bg-glass-hover: rgba(255, 255, 255, 0.12);
--color-border-glass-hover: rgba(255, 255, 255, 0.15);
--color-bg-thumbnail: rgba(0, 0, 0, 0.30); /* 视频缩略图 bg (dark) */
--color-bg-thumbnail-hover: rgba(0, 0, 0, 0.15); /* 缩略图 hover overlay */
--color-shadow-thumb: rgba(0, 0, 0, 0.30); /* 缩略图阴影 */
/* Backdrop-filter 标准五档 */
--bf-glass-sm: blur(12px) saturate(140%);
--bf-glass-md: blur(16px) saturate(160%);
--bf-glass-lg: blur(24px) saturate(180%);
--bf-glass-xl: blur(40px) saturate(180%);
} }
/*
LIGHT THEME OVERRIDES V2
规范:Vercel Geist 灰阶 + GitBook/Framer 玻璃 + Linear pastel aurora
关键变化:
- 玻璃面用 _白透明_ rgba(255,255,255,0.65) V1 是黑透明,方向反了
- aurora 不再 display:none,保留 pastel 紫蓝桃给玻璃穿透色源
- 实体卡 bg = #ffffff 纯白 + multi-layer shadow + 1px shadow-border
- LandingPage 不再强制 dark,跟随主题切换
- 玻璃顶边 inset highlight 白高光是 frosted glass 视觉标志
主色加深 18% 满足 WCAG AA
*/
[data-theme="light"] {
/* ── Page surfaces ── */
--color-bg-page: #fafafa;
--color-bg-input-bar: rgba(255, 255, 255, 0.85); /* 玻璃输入条 */
--color-bg-dropdown: rgba(255, 255, 255, 0.85); /* 玻璃下拉 */
--color-bg-upload: #ffffff; /* 实体上传区 */
--color-bg-card: #ffffff; /* ★ V2 核心:实体白卡,不是黑透明 */
--color-bg-hover: rgba(0, 0, 0, 0.04); /* 行 hover 黑透明保留(不是玻璃) */
--color-bg-row-hover: rgba(0, 0, 0, 0.03);
/* ── GLASS surfaces (sidebar / banner / modal overlay) ── */
--color-bg-glass: rgba(255, 255, 255, 0.65); /* ★ V2 核心:玻璃透白 */
--color-bg-glass-strong: rgba(255, 255, 255, 0.85);
--color-sidebar-bg: rgba(255, 255, 255, 0.70); /* 真正玻璃 */
--color-bg-sidebar: rgba(255, 255, 255, 0.70);
--color-sidebar-active: rgba(0, 0, 0, 0.06);
--color-sidebar-hover: rgba(0, 0, 0, 0.04);
/* ── Borders — Vercel shadow-border 风格 0.08 ── */
--color-border-input-bar: rgba(0, 0, 0, 0.10);
--color-border-card: rgba(0, 0, 0, 0.08);
--color-border-upload: rgba(0, 0, 0, 0.06);
--color-border-modal: rgba(0, 0, 0, 0.06);
--color-border-modal-soft: rgba(0, 0, 0, 0.05);
--color-border-modal-hover: #9ca3af;
--color-border-soft: rgba(0, 0, 0, 0.05);
--color-border-row: rgba(0, 0, 0, 0.06);
--color-border-glass-edge: rgba(255, 255, 255, 0.70); /* 玻璃外边白高光 */
/* ── Text — Vercel 黑白灰严格灰阶 ── */
--color-text-primary: #171717; /* Vercel Black 纯近黑,去紫调 */
--color-text-secondary: #525252; /* Gray 600 */
--color-text-tertiary: #888888; /* Gray 500 */
--color-text-quaternary: #a3a3a3; /* Gray 400 */
--color-text-disabled: #a3a3a3;
--color-text-light: #374151;
--color-text-monochrome: #4b5563;
--color-text-on-glass: rgba(23, 23, 23, 0.85);
--color-text-on-glass-soft: rgba(23, 23, 23, 0.55);
--color-text-on-glass-faint: rgba(23, 23, 23, 0.40);
/* Brand — 主色加深 18% (#6c63ff → #5048cc) */
--color-primary: #5048cc;
--color-primary-2: #7c3aed;
--color-primary-bg: rgba(80, 72, 204, 0.06);
--color-primary-bg-hover: rgba(80, 72, 204, 0.10);
--color-btn-send-active: #5048cc;
--color-btn-send-disabled: #d1d5db;
/* Status — 全部加深保持 AA 对比度 */
--color-success: #00a37e;
--color-danger: #d63a2a;
--color-warning: #d4860a;
--color-info: #0099cc;
--color-purple-accent: #7c3aed;
--color-success-bg: rgba(0, 163, 126, 0.10);
--color-success-bg-hover: rgba(0, 163, 126, 0.06);
--color-info-bg: rgba(0, 153, 204, 0.10);
--color-info-bg-hover: rgba(0, 153, 204, 0.06);
--color-info-bg-soft: rgba(0, 153, 204, 0.08);
--color-danger-bg: rgba(214, 58, 42, 0.10);
--color-danger-bg-hover: rgba(214, 58, 42, 0.06);
--color-danger-bg-soft: rgba(214, 58, 42, 0.05);
--color-danger-border: rgba(214, 58, 42, 0.18);
--color-danger-text: #dc2626;
--color-warning-bg: rgba(212, 134, 10, 0.10);
--color-warning-bg-hover: rgba(212, 134, 10, 0.06);
--color-warning-border: rgba(212, 134, 10, 0.25);
--color-warning-toast: #c97a1c;
--color-purple-bg: rgba(124, 58, 237, 0.10);
--color-purple-bg-hover: rgba(124, 58, 237, 0.06);
/* ── Modal & overlay — 浅色下整体减弱 ── */
--color-modal-overlay: rgba(0, 0, 0, 0.20);
--color-overlay-strong: rgba(0, 0, 0, 0.30);
--color-overlay-soft: rgba(0, 0, 0, 0.18);
--color-overlay-medium: rgba(0, 0, 0, 0.12);
--color-overlay-faint: rgba(0, 0, 0, 0.08);
--color-overlay-deep: rgba(0, 0, 0, 0.45);
--color-bg-modal: #ffffff;
--color-bg-modal-elevated: #ffffff;
--color-bg-modal-glass: rgba(255, 255, 255, 0.85); /* 略透气 */
--color-bg-modal-hover: #f5f5f5;
--color-bg-elevated: #f3f4f5;
--color-bg-placeholder: #ebebeb;
--color-bg-dropdown-elevated: rgba(255, 255, 255, 0.95);
--color-bg-video: #000;
--color-shadow-modal: rgba(0, 0, 0, 0.08);
--color-shadow-dropdown: rgba(0, 0, 0, 0.10);
/* ── V2 multi-layer shadows ── */
--shadow-card-light: 0 1px 2px rgba(0, 0, 0, 0.04),
0 4px 16px rgba(0, 0, 0, 0.06);
--shadow-glass-light: 0 8px 32px rgba(0, 0, 0, 0.06),
0 1px 2px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.60);
/* Charts — 浅色 tooltip 用白底 */
--color-tooltip-bg: rgba(255, 255, 255, 0.98);
--color-tooltip-border: rgba(0, 0, 0, 0.10);
--color-chart-axis: rgba(0, 0, 0, 0.10);
--color-chart-grid: rgba(0, 0, 0, 0.06);
--color-chart-area-from: rgba(80, 72, 204, 0.20);
--color-chart-area-to: rgba(80, 72, 204, 0.02);
--color-accent-2: #059669;
/* Misc */
--color-progress-track: rgba(0, 0, 0, 0.10);
--color-on-primary: #ffffff;
--color-on-overlay: #ffffff;
/* Brand mint accent (Auth modals) — deepen to teal in light */
--color-mint-accent: #0d9488;
--color-mint-accent-bg: rgba(13, 148, 136, 0.08);
--color-mint-accent-bg-hover: rgba(13, 148, 136, 0.14);
--color-mint-accent-border: rgba(13, 148, 136, 0.30);
--color-mint-accent-glow: rgba(13, 148, 136, 0.18);
/* Inset highlight — V2 改为白高光,玻璃顶边视觉标志 */
--color-inset-highlight: rgba(255, 255, 255, 0.50);
--color-inset-highlight-strong: rgba(255, 255, 255, 0.70);
/* 暖调 chip — GitBook 风格 */
--color-chip-warm-bg: #fff5eb;
--color-chip-warm-border: rgba(255, 180, 130, 0.40);
--color-chip-warm-text: #1a1a1a;
--color-chip-warm-badge-bg: rgba(255, 100, 50, 0.12);
--color-chip-warm-badge-text: #c2410c;
/* V2-fix: 主题感知 hover/border 浅色变黑透明 */
--color-bg-glass-hover: rgba(0, 0, 0, 0.06);
--color-border-glass-hover: rgba(0, 0, 0, 0.12);
--color-bg-thumbnail: rgba(0, 0, 0, 0.05); /* 浅色下浅淡 */
--color-bg-thumbnail-hover: rgba(0, 0, 0, 0.04);
--color-shadow-thumb: rgba(0, 0, 0, 0.10);
/* Mention pill */
--color-mention-bg: rgba(80, 72, 204, 0.10);
--color-mention-bg-hover: rgba(80, 72, 204, 0.16);
--color-mention-bg-active: rgba(80, 72, 204, 0.12);
--color-mention-text: #5048cc;
--color-mention-text-hover: #3a3380;
/* Shimmer */
--color-shimmer-purple-soft: rgba(80, 72, 204, 0.03);
--color-shimmer-purple-mid: rgba(80, 72, 204, 0.06);
--color-shimmer-purple-2-mid: rgba(124, 58, 237, 0.08);
/* Danger hover (brighter red) */
--color-danger-hover: #ef4444;
--color-danger-hover-bg: rgba(239, 68, 68, 0.06);
--color-danger-hover-bg-strong: rgba(239, 68, 68, 0.08);
--color-danger-hover-border: rgba(239, 68, 68, 0.20);
/* Info hover */
--color-info-hover: #0088b8;
--color-info-hover-2: #1aa9d4;
--color-info-shadow-soft: rgba(0, 153, 204, 0.20);
--color-info-shadow-strong: rgba(0, 153, 204, 0.35);
/* White alpha on dark media — 保留白色徽章语义 */
--color-bg-on-media: rgba(255, 255, 255, 0.90);
--color-bg-on-media-hover: rgba(255, 255, 255, 1.0);
/* Scrollbar */
--color-scrollbar-thumb: rgba(0, 0, 0, 0.15);
--color-scrollbar-thumb-hover: rgba(0, 0, 0, 0.30);
/* Aurora — V2 保留 pastel 紫蓝桃,给玻璃面提供穿透色源V1 错点:display:none */
--color-aurora-1: rgba(180, 167, 255, 0.30); /* pastel 紫 */
--color-aurora-2: rgba(167, 200, 255, 0.28); /* pastel 蓝青 */
--color-aurora-3: rgba(220, 167, 255, 0.22); /* pastel 粉紫 */
--color-aurora-peach: rgba(255, 180, 130, 0.25); /* pastel 桃 */
--color-cursor-glow: rgba(80, 72, 204, 0.06);
--color-grid-line: rgba(0, 0, 0, 0.025);
}
/* V2 删除原 [data-theme="light"] .aurora-bg { display: none }
极光在浅色下保留 pastel,给玻璃面 backdrop-filter 提供穿透色源 */
/*
Reset / globals
*/
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
@ -67,7 +439,7 @@ body {
scrollbar-color: transparent transparent; scrollbar-color: transparent transparent;
} }
*:hover { *:hover {
scrollbar-color: rgba(255, 255, 255, 0.1) transparent; scrollbar-color: var(--color-scrollbar-thumb) transparent;
} }
/* Scrollbar: Webkit — hidden by default, visible on hover */ /* Scrollbar: Webkit — hidden by default, visible on hover */
@ -83,10 +455,10 @@ body {
transition: background 0.2s; transition: background 0.2s;
} }
*:hover::-webkit-scrollbar-thumb { *:hover::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1); background: var(--color-scrollbar-thumb);
} }
*:hover::-webkit-scrollbar-thumb:hover { *:hover::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2); background: var(--color-scrollbar-thumb-hover);
} }
/* /*
@ -115,7 +487,7 @@ body {
height: 600px; height: 600px;
top: -10%; top: -10%;
right: -5%; right: -5%;
background: radial-gradient(circle, rgba(108, 99, 255, 0.6) 0%, transparent 70%); background: radial-gradient(circle, var(--color-aurora-1) 0%, transparent 70%);
animation: aurora-drift-1 20s ease-in-out infinite alternate; animation: aurora-drift-1 20s ease-in-out infinite alternate;
} }
@ -124,7 +496,7 @@ body {
height: 500px; height: 500px;
bottom: -5%; bottom: -5%;
left: -5%; left: -5%;
background: radial-gradient(circle, rgba(59, 130, 246, 0.5) 0%, transparent 70%); background: radial-gradient(circle, var(--color-aurora-2) 0%, transparent 70%);
animation: aurora-drift-2 25s ease-in-out infinite alternate; animation: aurora-drift-2 25s ease-in-out infinite alternate;
} }
@ -149,7 +521,7 @@ body {
top: 40%; top: 40%;
left: 50%; left: 50%;
border-radius: 50%; border-radius: 50%;
background: radial-gradient(circle, rgba(139, 92, 246, 0.35) 0%, transparent 70%); background: radial-gradient(circle, var(--color-aurora-3) 0%, transparent 70%);
filter: blur(100px); filter: blur(100px);
opacity: 0.3; opacity: 0.3;
will-change: transform; will-change: transform;
@ -187,7 +559,7 @@ body {
pointer-events: none; pointer-events: none;
background: radial-gradient( background: radial-gradient(
600px circle at var(--mouse-x, 50%) var(--mouse-y, 50%), 600px circle at var(--mouse-x, 50%) var(--mouse-y, 50%),
rgba(108, 99, 255, 0.06) 0%, var(--color-cursor-glow) 0%,
transparent 60% transparent 60%
); );
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
@ -202,8 +574,8 @@ body {
z-index: 0; z-index: 0;
pointer-events: none; pointer-events: none;
background-image: background-image:
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px), linear-gradient(var(--color-grid-line) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px); linear-gradient(90deg, var(--color-grid-line) 1px, transparent 1px);
background-size: 64px 64px; background-size: 64px 64px;
mask-image: radial-gradient(ellipse 80% 60% at 50% 50%, black 20%, transparent 100%); mask-image: radial-gradient(ellipse 80% 60% at 50% 50%, black 20%, transparent 100%);
-webkit-mask-image: radial-gradient(ellipse 80% 60% at 50% 50%, black 20%, transparent 100%); -webkit-mask-image: radial-gradient(ellipse 80% 60% at 50% 50%, black 20%, transparent 100%);

16
web/src/lib/themeColor.ts Normal file
View File

@ -0,0 +1,16 @@
/**
* Read a CSS custom property value from :root.
* Use this inside ECharts options / canvas drawing so colors track CSS variables
* (e.g. --color-text-secondary) instead of being hard-coded.
*
* Stage 3 will add re-render-on-theme-change via a `key={theme}` on chart hosts.
*
* @example c('text-secondary') // → '#8b8ea8' under dark theme
* @example c('chart-area-from') // → 'rgba(108, 99, 255, 0.25)' under dark
*/
export function c(token: string): string {
if (typeof window === 'undefined') return '';
return getComputedStyle(document.documentElement)
.getPropertyValue('--color-' + token)
.trim();
}

View File

@ -10,6 +10,7 @@
flex: 1; padding: 16px 20px; flex: 1; padding: 16px 20px;
background: var(--color-bg-card); border: 1px solid var(--color-border-card); background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); border-radius: var(--radius-card);
box-shadow: var(--shadow-card-light);
} }
.statLabel { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 4px; } .statLabel { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 4px; }
@ -21,6 +22,7 @@
.accordionItem { .accordionItem {
background: var(--color-bg-card); border: 1px solid var(--color-border-card); background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); overflow: hidden; border-radius: var(--radius-card); overflow: hidden;
box-shadow: var(--shadow-card-light);
} }
.accordionHeader { .accordionHeader {
@ -29,7 +31,7 @@
transition: background 0.15s; transition: background 0.15s;
} }
.accordionHeader:hover { background: rgba(255,255,255,0.03); } .accordionHeader:hover { background: var(--color-sidebar-hover); }
.chevron { .chevron {
width: 16px; height: 16px; flex-shrink: 0; width: 16px; height: 16px; flex-shrink: 0;
@ -53,7 +55,7 @@
.adminBadge { .adminBadge {
font-size: 11px; padding: 1px 6px; border-radius: 4px; font-size: 11px; padding: 1px 6px; border-radius: 4px;
background: rgba(0, 184, 230, 0.12); color: #00b8e6; background: var(--color-info-bg-soft); color: var(--color-info);
} }
/* Accordion body — team members or video grid */ /* Accordion body — team members or video grid */
@ -71,9 +73,9 @@
transition: background 0.15s; transition: background 0.15s;
} }
.memberItem:hover { background: rgba(255,255,255,0.03); } .memberItem:hover { background: var(--color-sidebar-hover); }
.memberItem + .memberItem { border-top: 1px solid rgba(255,255,255,0.04); } .memberItem + .memberItem { border-top: 1px solid var(--color-border-row); }
.memberName { font-size: 13px; color: var(--color-text-primary); flex: 1; display: flex; align-items: center; gap: 8px; } .memberName { font-size: 13px; color: var(--color-text-primary); flex: 1; display: flex; align-items: center; gap: 8px; }
@ -82,7 +84,7 @@
max-height: 440px; max-height: 440px;
overflow-y: auto; overflow-y: auto;
padding: 12px 20px 12px 40px; padding: 12px 20px 12px 40px;
border-top: 1px solid rgba(255,255,255,0.04); border-top: 1px solid var(--color-border-row);
} }
.videoGrid { .videoGrid {
@ -100,7 +102,7 @@
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
background: rgba(0, 0, 0, 0.3); background: var(--color-overlay-medium);
cursor: pointer; cursor: pointer;
transition: transform 0.15s; transition: transform 0.15s;
} }
@ -112,24 +114,24 @@
} }
.thumbPlaceholder { .thumbPlaceholder {
width: 100%; height: 100%; background: #1a1a24; width: 100%; height: 100%; background: var(--color-bg-dropdown-elevated);
} }
.durationBadge { .durationBadge {
position: absolute; bottom: 4px; left: 4px; position: absolute; bottom: 4px; left: 4px;
padding: 1px 5px; border-radius: 3px; padding: 1px 5px; border-radius: 3px;
background: rgba(0, 0, 0, 0.6); color: #fff; background: var(--color-modal-overlay); color: var(--color-on-overlay);
font-size: 10px; font-variant-numeric: tabular-nums; font-size: 10px; font-variant-numeric: tabular-nums;
} }
.thumbOverlay { .thumbOverlay {
position: absolute; inset: 0; position: absolute; inset: 0;
background: rgba(0, 0, 0, 0.15); pointer-events: none; background: var(--color-overlay-faint); pointer-events: none;
} }
.timeBadge { .timeBadge {
position: absolute; bottom: 4px; right: 4px; position: absolute; bottom: 4px; right: 4px;
font-size: 10px; color: rgba(255,255,255,0.5); font-size: 10px; color: var(--color-text-on-glass-soft);
} }
.loadMore { .loadMore {
@ -142,7 +144,7 @@
border-radius: 6px; cursor: pointer; transition: all 0.15s; border-radius: 6px; cursor: pointer; transition: all 0.15s;
} }
.loadMoreBtn:hover { background: rgba(255,255,255,0.04); color: var(--color-text-primary); } .loadMoreBtn:hover { background: var(--color-bg-upload); color: var(--color-text-primary); }
.empty { .empty {
color: var(--color-text-disabled); font-size: 13px; color: var(--color-text-disabled); font-size: 13px;

View File

@ -2,14 +2,23 @@
display: flex; display: flex;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
background: var(--color-bg-page); /* V2: transparent 让全局 AmbientBackground pastel aurora 在主区也能隐约透出 */
background: transparent;
position: relative;
z-index: 2;
} }
.sidebar { .sidebar {
width: var(--sidebar-width); width: var(--sidebar-width);
min-width: var(--sidebar-width); min-width: var(--sidebar-width);
background: var(--color-bg-sidebar); background: var(--color-bg-sidebar);
/* V2 玻璃 */
backdrop-filter: var(--bf-glass-md);
-webkit-backdrop-filter: var(--bf-glass-md);
border-right: 1px solid var(--color-border-card); border-right: 1px solid var(--color-border-card);
/* V2 玻璃顶边白高光 + 右侧立体感 */
box-shadow: inset 0 1px 0 var(--color-inset-highlight),
2px 0 12px rgba(0, 0, 0, 0.04);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
transition: width 0.2s ease, min-width 0.2s ease; transition: width 0.2s ease, min-width 0.2s ease;
@ -101,6 +110,28 @@
gap: 8px; gap: 8px;
} }
/* Theme toggle button (super admin sidebar) */
.themeToggle {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--color-text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
width: 100%;
text-align: left;
}
.themeToggle:hover {
background: var(--color-sidebar-hover);
color: var(--color-primary);
}
.userInfo { .userInfo {
display: flex; display: flex;
align-items: center; align-items: center;
@ -114,7 +145,7 @@
min-width: 32px; min-width: 32px;
border-radius: 50%; border-radius: 50%;
background: var(--color-primary); background: var(--color-primary);
color: #fff; color: var(--color-on-primary);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View File

@ -1,5 +1,6 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom'; import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import { useThemeStore } from '../store/theme';
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { authApi } from '../lib/api'; import { authApi } from '../lib/api';
import logoImg from '../assets/logo_32.png'; import logoImg from '../assets/logo_32.png';
@ -20,6 +21,8 @@ const navItems = [
export function AdminLayout() { export function AdminLayout() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout); const logout = useAuthStore((s) => s.logout);
const theme = useThemeStore((s) => s.theme);
const toggleTheme = useThemeStore((s) => s.toggleTheme);
const navigate = useNavigate(); const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [pwModalOpen, setPwModalOpen] = useState(false); const [pwModalOpen, setPwModalOpen] = useState(false);
@ -94,6 +97,25 @@ export function AdminLayout() {
</nav> </nav>
<div className={styles.sidebarFooter}> <div className={styles.sidebarFooter}>
{/* 主题切换 — 月亮/太阳 SVG,跟 components/Sidebar 一致 */}
<button
className={styles.themeToggle}
onClick={toggleTheme}
title={theme === 'dark' ? '切换到浅色主题' : '切换到深色主题'}
aria-label={theme === 'dark' ? '切换到浅色主题' : '切换到深色主题'}
>
{theme === 'dark' ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
)}
{!collapsed && <span>{theme === 'dark' ? '浅色' : '深色'}</span>}
</button>
<div className={styles.userInfo}> <div className={styles.userInfo}>
<div className={styles.userAvatar}>{user?.username.charAt(0).toUpperCase()}</div> <div className={styles.userAvatar}>{user?.username.charAt(0).toUpperCase()}</div>
{!collapsed && ( {!collapsed && (
@ -114,9 +136,9 @@ export function AdminLayout() {
</main> </main>
{pwModalOpen && ( {pwModalOpen && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} <div style={{ position: 'fixed', inset: 0, background: 'var(--color-overlay-soft)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}
onClick={() => setPwModalOpen(false)}> onClick={() => setPwModalOpen(false)}>
<div style={{ background: '#16161e', borderRadius: '12px', padding: '24px', width: '360px', border: '1px solid var(--color-border-card)' }} <div style={{ background: 'var(--color-bg-modal-elevated)', borderRadius: '12px', padding: '24px', width: '360px', border: '1px solid var(--color-border-card)' }}
onClick={(e) => e.stopPropagation()}> onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: '0 0 16px', color: 'var(--color-text-primary)', fontSize: '16px' }}></h3> <h3 style={{ margin: '0 0 16px', color: 'var(--color-text-primary)', fontSize: '16px' }}></h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
@ -131,7 +153,7 @@ export function AdminLayout() {
<button onClick={() => setPwModalOpen(false)} <button onClick={() => setPwModalOpen(false)}
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid var(--color-border-card)', background: 'transparent', color: 'var(--color-text-secondary)', cursor: 'pointer', fontSize: '13px' }}></button> style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid var(--color-border-card)', background: 'transparent', color: 'var(--color-text-secondary)', cursor: 'pointer', fontSize: '13px' }}></button>
<button onClick={handleChangePassword} disabled={pwSaving} <button onClick={handleChangePassword} disabled={pwSaving}
style={{ padding: '6px 16px', borderRadius: '6px', border: 'none', background: 'var(--color-primary)', color: '#fff', cursor: 'pointer', fontSize: '13px', opacity: pwSaving ? 0.6 : 1 }}> style={{ padding: '6px 16px', borderRadius: '6px', border: 'none', background: 'var(--color-primary)', color: 'var(--color-on-primary)', cursor: 'pointer', fontSize: '13px', opacity: pwSaving ? 0.6 : 1 }}>
{pwSaving ? '修改中...' : '确认修改'} {pwSaving ? '修改中...' : '确认修改'}
</button> </button>
</div> </div>

View File

@ -144,8 +144,8 @@ export function AnomalyLogPage() {
<td> <td>
<span style={{ <span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 12, whiteSpace: 'nowrap', padding: '2px 8px', borderRadius: 4, fontSize: 12, whiteSpace: 'nowrap',
background: a.level === 'critical' ? 'rgba(255, 77, 79, 0.15)' : 'rgba(250, 173, 20, 0.15)', background: a.level === 'critical' ? 'var(--color-danger-bg)' : 'var(--color-warning-bg)',
color: a.level === 'critical' ? '#ff4d4f' : '#faad14', color: a.level === 'critical' ? 'var(--color-danger)' : 'var(--color-warning)',
}}> }}>
{a.level === 'critical' ? '严重' : '警告'} {a.level === 'critical' ? '严重' : '警告'}
</span> </span>
@ -164,7 +164,7 @@ export function AnomalyLogPage() {
</td> </td>
<td> <td>
{a.auto_disabled ? ( {a.auto_disabled ? (
<span style={{ fontSize: 12, color: '#ff4d4f' }}> <span style={{ fontSize: 12, color: 'var(--color-danger)' }}>
{a.disabled_target === 'team' ? '团队' : '用户'} {a.disabled_target === 'team' ? '团队' : '用户'}
</span> </span>
) : a.alerted ? ( ) : a.alerted ? (

View File

@ -15,7 +15,7 @@
/* Tab header */ /* Tab header */
.tabHeader { .tabHeader {
padding: 20px 32px 0; padding: 20px 32px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06); border-bottom: 1px solid var(--color-border-soft);
} }
.tabs { .tabs {
@ -125,7 +125,7 @@
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
background: rgba(0, 0, 0, 0.3); background: var(--color-bg-thumbnail);
cursor: pointer; cursor: pointer;
transition: transform 0.15s; transition: transform 0.15s;
} }
@ -144,7 +144,7 @@
.thumbPlaceholder { .thumbPlaceholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: #1a1a24; background: var(--color-bg-dropdown-elevated);
} }
.durationBadge { .durationBadge {
@ -153,8 +153,8 @@
left: 6px; left: 6px;
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
background: rgba(0, 0, 0, 0.6); background: var(--color-modal-overlay);
color: #fff; color: var(--color-on-overlay);
font-size: 11px; font-size: 11px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@ -162,6 +162,6 @@
.thumbOverlay { .thumbOverlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.15); background: var(--color-bg-thumbnail-hover);
pointer-events: none; pointer-events: none;
} }

View File

@ -186,8 +186,8 @@ export function AssetsPage() {
onClick={loadMore} onClick={loadMore}
disabled={isLoadingMore} disabled={isLoadingMore}
style={{ style={{
background: 'rgba(255,255,255,0.06)', background: 'var(--color-bg-card)',
border: '1px solid rgba(255,255,255,0.1)', border: '1px solid var(--color-border-card)',
borderRadius: 8, borderRadius: 8,
padding: '8px 32px', padding: '8px 32px',
color: 'var(--color-text-secondary)', color: 'var(--color-text-secondary)',

View File

@ -8,7 +8,7 @@
} }
.searchInput:focus { border-color: var(--color-primary); } .searchInput:focus { border-color: var(--color-primary); }
.dateSep { color: var(--color-text-secondary); font-size: 13px; } .dateSep { color: var(--color-text-secondary); font-size: 13px; }
.searchBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: #fff; font-size: 13px; cursor: pointer; } .searchBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: var(--color-on-primary); font-size: 13px; cursor: pointer; }
.searchBtn:hover { opacity: 0.9; } .searchBtn:hover { opacity: 0.9; }
.refreshBtn { .refreshBtn {
padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s; padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s;
@ -19,23 +19,24 @@
.tableWrapper { .tableWrapper {
background: var(--color-bg-card); border: 1px solid var(--color-border-card); background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); overflow-x: auto; border-radius: var(--radius-card); overflow-x: auto;
box-shadow: var(--shadow-card-light);
} }
.table { width: 100%; border-collapse: collapse; font-size: 13px; } .table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th { padding: 12px 16px; text-align: left; color: var(--color-text-secondary); font-weight: 500; border-bottom: 1px solid var(--color-border-card); white-space: nowrap; } .table th { padding: 12px 16px; text-align: left; color: var(--color-text-secondary); font-weight: 500; border-bottom: 1px solid var(--color-border-card); white-space: nowrap; }
.table td { padding: 12px 16px; color: var(--color-text-primary); border-bottom: 1px solid rgba(42, 42, 56, 0.5); vertical-align: top; } .table td { padding: 12px 16px; color: var(--color-text-primary); border-bottom: 1px solid var(--color-border-row); vertical-align: top; }
.table tr:last-child td { border-bottom: none; } .table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: rgba(255, 255, 255, 0.02); } .table tr:hover td { background: var(--color-border-row); }
.timeCell { white-space: nowrap; font-size: 12px; color: var(--color-text-secondary); } .timeCell { white-space: nowrap; font-size: 12px; color: var(--color-text-secondary); }
.actionBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; background: rgba(0, 184, 230, 0.12); color: var(--color-primary); white-space: nowrap; } .actionBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; background: var(--color-info-bg-soft); color: var(--color-primary); white-space: nowrap; }
.targetCell { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .targetCell { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ipCell { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--color-text-secondary); white-space: nowrap; } .ipCell { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--color-text-secondary); white-space: nowrap; }
.changeDetail { font-size: 12px; line-height: 1.6; } .changeDetail { font-size: 12px; line-height: 1.6; }
.changeItem { display: flex; gap: 4px; flex-wrap: wrap; } .changeItem { display: flex; gap: 4px; flex-wrap: wrap; }
.changeField { color: #8b8ea8; } .changeField { color: var(--color-text-secondary); }
.changeOld { color: var(--color-danger); text-decoration: line-through; } .changeOld { color: var(--color-danger); text-decoration: line-through; }
.changeArrow { color: #8b8ea8; } .changeArrow { color: var(--color-text-secondary); }
.changeNew { color: var(--color-success); } .changeNew { color: var(--color-success); }
.empty { text-align: center; color: var(--color-text-secondary); padding: 40px; } .empty { text-align: center; color: var(--color-text-secondary); padding: 40px; }
@ -51,4 +52,4 @@
} }
.pageButtons button:hover:not(:disabled) { background: var(--color-sidebar-hover); color: var(--color-text-primary); } .pageButtons button:hover:not(:disabled) { background: var(--color-sidebar-hover); color: var(--color-text-primary); }
.pageButtons button:disabled { opacity: 0.4; cursor: not-allowed; } .pageButtons button:disabled { opacity: 0.4; cursor: not-allowed; }
.activePage { background: var(--color-primary) !important; color: #fff !important; border-color: var(--color-primary) !important; } .activePage { background: var(--color-primary) !important; color: var(--color-on-primary) !important; border-color: var(--color-primary) !important; }

View File

@ -89,7 +89,7 @@ function renderChanges(before: Record<string, unknown> | null, after: Record<str
return items.length > 0 return items.length > 0
? <div className={styles.changeDetail}>{items}</div> ? <div className={styles.changeDetail}>{items}</div>
: <span style={{ color: '#8b8ea8' }}></span>; : <span style={{ color: 'var(--color-text-secondary)' }}></span>;
} }
export function AuditLogsPage() { export function AuditLogsPage() {
@ -188,7 +188,7 @@ export function AuditLogsPage() {
<td><span className={styles.actionBadge}>{log.action_display}</span></td> <td><span className={styles.actionBadge}>{log.action_display}</span></td>
<td className={styles.targetCell}> <td className={styles.targetCell}>
{log.target_name || '-'} {log.target_name || '-'}
{log.target_type && <span style={{ color: '#8b8ea8', fontSize: 11, marginLeft: 4 }}>({log.target_type})</span>} {log.target_type && <span style={{ color: 'var(--color-text-secondary)', fontSize: 11, marginLeft: 4 }}>({log.target_type})</span>}
</td> </td>
<td>{renderChanges(log.before, log.after)}</td> <td>{renderChanges(log.before, log.after)}</td>
<td className={styles.ipCell}>{log.ip_address || '-'}</td> <td className={styles.ipCell}>{log.ip_address || '-'}</td>

View File

@ -54,7 +54,7 @@
.input { .input {
height: 44px; height: 44px;
padding: 0 14px; padding: 0 14px;
background: rgba(255, 255, 255, 0.04); background: var(--color-bg-upload);
border: 1px solid var(--color-border-input-bar); border: 1px solid var(--color-border-input-bar);
border-radius: 10px; border-radius: 10px;
color: var(--color-text-primary); color: var(--color-text-primary);
@ -72,18 +72,18 @@
} }
.error { .error {
color: #ff4d4f; color: var(--color-danger-text);
font-size: 13px; font-size: 13px;
text-align: center; text-align: center;
padding: 8px; padding: 8px;
background: rgba(255, 77, 79, 0.08); background: var(--color-danger-bg-soft);
border-radius: 8px; border-radius: 8px;
} }
.submitBtn { .submitBtn {
height: 44px; height: 44px;
background: var(--color-primary); background: var(--color-primary);
color: #fff; color: var(--color-on-primary);
border: none; border: none;
border-radius: 10px; border-radius: 10px;
font-size: 15px; font-size: 15px;

View File

@ -21,6 +21,7 @@
border: 1px solid var(--color-border-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); border-radius: var(--radius-card);
padding: 20px; padding: 20px;
box-shadow: var(--shadow-card-light);
} }
.statLabel { .statLabel {
@ -48,12 +49,12 @@
.positive { .positive {
color: var(--color-success); color: var(--color-success);
background: rgba(0, 184, 148, 0.1); background: var(--color-success-bg-hover);
} }
.negative { .negative {
color: var(--color-danger); color: var(--color-danger);
background: rgba(231, 76, 60, 0.1); background: var(--color-danger-bg-hover);
} }
.chartSection { .chartSection {
@ -72,6 +73,7 @@
border: 1px solid var(--color-border-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); border-radius: var(--radius-card);
padding: 16px; padding: 16px;
box-shadow: var(--shadow-card-light);
} }
/* Skeleton loading */ /* Skeleton loading */

View File

@ -7,6 +7,8 @@ import { CanvasRenderer } from 'echarts/renderers';
import { adminApi } from '../lib/api'; import { adminApi } from '../lib/api';
import type { AdminStats } from '../types'; import type { AdminStats } from '../types';
import { showToast } from '../components/Toast'; import { showToast } from '../components/Toast';
import { c } from '../lib/themeColor';
import { useThemeStore } from '../store/theme';
import styles from './DashboardPage.module.css'; import styles from './DashboardPage.module.css';
echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, CanvasRenderer]); echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, CanvasRenderer]);
@ -14,6 +16,9 @@ echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendCompone
export function DashboardPage() { export function DashboardPage() {
const [stats, setStats] = useState<AdminStats | null>(null); const [stats, setStats] = useState<AdminStats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// theme 变化时强制 ECharts 重渲染option 里 c() 读 CSS var
// 订阅 theme 触发本组件 re-render并用 key 让图表 unmount→remount
const theme = useThemeStore((s) => s.theme);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
try { try {
@ -54,9 +59,9 @@ export function DashboardPage() {
const trendOption: echarts.EChartsCoreOption = { const trendOption: echarts.EChartsCoreOption = {
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
backgroundColor: 'rgba(13, 13, 26, 0.95)', backgroundColor: c('tooltip-bg'),
borderColor: 'rgba(255, 255, 255, 0.10)', borderColor: c('tooltip-border'),
textStyle: { color: '#f1f0ff', fontSize: 12 }, textStyle: { color: c('text-primary'), fontSize: 12 },
formatter: (params: unknown) => { formatter: (params: unknown) => {
const p = (params as { name: string; value: number }[])[0]; const p = (params as { name: string; value: number }[])[0];
return `${p.name}<br/>消费: ¥${p.value.toFixed(2)}`; return `${p.name}<br/>消费: ¥${p.value.toFixed(2)}`;
@ -66,27 +71,27 @@ export function DashboardPage() {
xAxis: { xAxis: {
type: 'category', type: 'category',
data: stats.daily_trend.map((d) => d.date.slice(5)), data: stats.daily_trend.map((d) => d.date.slice(5)),
axisLabel: { color: '#8b8ea8', fontSize: 11 }, axisLabel: { color: c('text-secondary'), fontSize: 11 },
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } }, axisLine: { lineStyle: { color: c('chart-axis') } },
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
axisLabel: { color: '#8b8ea8', fontSize: 11 }, axisLabel: { color: c('text-secondary'), fontSize: 11 },
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } }, splitLine: { lineStyle: { color: c('chart-grid') } },
}, },
dataZoom: [{ type: 'inside', start: 0, end: 100 }], dataZoom: [{ type: 'inside', start: 0, end: 100 }],
series: [{ series: [{
type: 'line', type: 'line',
data: stats.daily_trend.map((d) => d.cost), data: stats.daily_trend.map((d) => d.cost),
smooth: true, smooth: true,
lineStyle: { color: '#6c63ff', width: 2 }, lineStyle: { color: c('primary'), width: 2 },
areaStyle: { areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(108, 99, 255, 0.25)' }, { offset: 0, color: c('chart-area-from') },
{ offset: 1, color: 'rgba(108, 99, 255, 0.02)' }, { offset: 1, color: c('chart-area-to') },
]), ]),
}, },
itemStyle: { color: '#6c63ff' }, itemStyle: { color: c('primary') },
}], }],
}; };
@ -95,21 +100,21 @@ export function DashboardPage() {
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
axisPointer: { type: 'shadow' }, axisPointer: { type: 'shadow' },
backgroundColor: 'rgba(13, 13, 26, 0.95)', backgroundColor: c('tooltip-bg'),
borderColor: 'rgba(255, 255, 255, 0.10)', borderColor: c('tooltip-border'),
textStyle: { color: '#f1f0ff', fontSize: 12 }, textStyle: { color: c('text-primary'), fontSize: 12 },
}, },
grid: { left: 80, right: 40, top: 10, bottom: 20 }, grid: { left: 80, right: 40, top: 10, bottom: 20 },
xAxis: { xAxis: {
type: 'value', type: 'value',
axisLabel: { color: '#8b8ea8', fontSize: 11 }, axisLabel: { color: c('text-secondary'), fontSize: 11 },
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } }, splitLine: { lineStyle: { color: c('chart-grid') } },
}, },
yAxis: { yAxis: {
type: 'category', type: 'category',
data: sortedTeams.map((t) => t.name), data: sortedTeams.map((t) => t.name),
axisLabel: { color: '#8b8ea8', fontSize: 12 }, axisLabel: { color: c('text-secondary'), fontSize: 12 },
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } }, axisLine: { lineStyle: { color: c('chart-axis') } },
}, },
series: [{ series: [{
type: 'bar', type: 'bar',
@ -117,15 +122,15 @@ export function DashboardPage() {
barWidth: 16, barWidth: 16,
itemStyle: { itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [ color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#00b8e6' }, { offset: 0, color: c('info') },
{ offset: 1, color: '#06d6a0' }, { offset: 1, color: c('accent-2') },
]), ]),
borderRadius: [0, 4, 4, 0], borderRadius: [0, 4, 4, 0],
}, },
label: { label: {
show: true, show: true,
position: 'right', position: 'right',
color: '#8b8ea8', color: c('text-secondary'),
fontSize: 11, fontSize: 11,
formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`, formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`,
}, },
@ -137,21 +142,21 @@ export function DashboardPage() {
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
axisPointer: { type: 'shadow' }, axisPointer: { type: 'shadow' },
backgroundColor: 'rgba(13, 13, 26, 0.95)', backgroundColor: c('tooltip-bg'),
borderColor: 'rgba(255, 255, 255, 0.10)', borderColor: c('tooltip-border'),
textStyle: { color: '#f1f0ff', fontSize: 12 }, textStyle: { color: c('text-primary'), fontSize: 12 },
}, },
grid: { left: 80, right: 40, top: 10, bottom: 20 }, grid: { left: 80, right: 40, top: 10, bottom: 20 },
xAxis: { xAxis: {
type: 'value', type: 'value',
axisLabel: { color: '#8b8ea8', fontSize: 11 }, axisLabel: { color: c('text-secondary'), fontSize: 11 },
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } }, splitLine: { lineStyle: { color: c('chart-grid') } },
}, },
yAxis: { yAxis: {
type: 'category', type: 'category',
data: sortedUsers.map((u) => u.username), data: sortedUsers.map((u) => u.username),
axisLabel: { color: '#8b8ea8', fontSize: 12 }, axisLabel: { color: c('text-secondary'), fontSize: 12 },
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } }, axisLine: { lineStyle: { color: c('chart-axis') } },
}, },
series: [{ series: [{
type: 'bar', type: 'bar',
@ -159,15 +164,15 @@ export function DashboardPage() {
barWidth: 16, barWidth: 16,
itemStyle: { itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [ color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#6c63ff' }, { offset: 0, color: c('primary') },
{ offset: 1, color: '#8b5cf6' }, { offset: 1, color: c('primary-2') },
]), ]),
borderRadius: [0, 4, 4, 0], borderRadius: [0, 4, 4, 0],
}, },
label: { label: {
show: true, show: true,
position: 'right', position: 'right',
color: '#8b8ea8', color: c('text-secondary'),
fontSize: 11, fontSize: 11,
formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`, formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`,
}, },
@ -217,7 +222,7 @@ export function DashboardPage() {
<div className={styles.chartSection}> <div className={styles.chartSection}>
<h2 className={styles.sectionTitle}>30 · </h2> <h2 className={styles.sectionTitle}>30 · </h2>
<div className={styles.chartWrapper}> <div className={styles.chartWrapper}>
<ReactEChartsCore echarts={echarts} option={trendOption} style={{ height: 320 }} /> <ReactEChartsCore key={`trend-${theme}`} echarts={echarts} option={trendOption} style={{ height: 320 }} />
</div> </div>
</div> </div>
@ -227,7 +232,7 @@ export function DashboardPage() {
<div className={styles.chartSection}> <div className={styles.chartSection}>
<h2 className={styles.sectionTitle}></h2> <h2 className={styles.sectionTitle}></h2>
<div className={styles.chartWrapper}> <div className={styles.chartWrapper}>
<ReactEChartsCore echarts={echarts} option={teamBarOption} style={{ height: Math.max(300, sortedTeams.length * 36) }} /> <ReactEChartsCore key={`teams-${theme}`} echarts={echarts} option={teamBarOption} style={{ height: Math.max(300, sortedTeams.length * 36) }} />
</div> </div>
</div> </div>
)} )}
@ -235,7 +240,7 @@ export function DashboardPage() {
<div className={styles.chartSection}> <div className={styles.chartSection}>
<h2 className={styles.sectionTitle}>Top 10 · </h2> <h2 className={styles.sectionTitle}>Top 10 · </h2>
<div className={styles.chartWrapper}> <div className={styles.chartWrapper}>
<ReactEChartsCore echarts={echarts} option={barOption} style={{ height: Math.max(300, sortedUsers.length * 36) }} /> <ReactEChartsCore key={`users-${theme}`} echarts={echarts} option={barOption} style={{ height: Math.max(300, sortedUsers.length * 36) }} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -6,7 +6,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
background: #000; background: var(--color-bg-page);
z-index: 2; z-index: 2;
} }
@ -36,7 +36,7 @@
width: 80px; width: 80px;
height: 80px; height: 80px;
margin-bottom: 28px; margin-bottom: 28px;
filter: drop-shadow(0 0 40px rgba(126, 220, 200, 0.25)); filter: drop-shadow(0 0 40px var(--color-mint-accent-glow));
animation: fadeUp 1.2s ease-out 0.1s both; animation: fadeUp 1.2s ease-out 0.1s both;
} }
@ -44,7 +44,7 @@
font-family: 'Space Grotesk', sans-serif; font-family: 'Space Grotesk', sans-serif;
font-size: 48px; font-size: 48px;
font-weight: 300; font-weight: 300;
color: #f1f0ff; color: var(--color-text-primary);
letter-spacing: 0.1em; letter-spacing: 0.1em;
margin-bottom: 12px; margin-bottom: 12px;
line-height: 1.1; line-height: 1.1;
@ -55,7 +55,7 @@
font-family: 'Space Grotesk', sans-serif; font-family: 'Space Grotesk', sans-serif;
font-size: 14px; font-size: 14px;
font-weight: 300; font-weight: 300;
color: rgba(255, 255, 255, 0.5); color: var(--color-text-on-glass-soft);
letter-spacing: 0.15em; letter-spacing: 0.15em;
margin-bottom: 48px; margin-bottom: 48px;
animation: fadeUp 1.2s ease-out 0.3s both; animation: fadeUp 1.2s ease-out 0.3s both;
@ -85,45 +85,47 @@
border-radius: 10px; border-radius: 10px;
cursor: pointer; cursor: pointer;
transition: all 0.3s; transition: all 0.3s;
backdrop-filter: blur(12px); backdrop-filter: var(--bf-glass-sm);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: var(--bf-glass-sm);
} }
.btnPrimary { .btnPrimary {
background: rgba(120, 220, 200, 0.12); background: var(--color-mint-accent-bg);
border: 1px solid rgba(120, 220, 200, 0.3); border: 1px solid var(--color-mint-accent-border);
} }
.btnPrimary .btnName { .btnPrimary .btnName {
font-family: 'Space Grotesk', sans-serif; font-family: 'Space Grotesk', sans-serif;
font-size: 15px; font-size: 15px;
font-weight: 500; font-weight: 500;
color: #7edcc8; color: var(--color-mint-accent);
} }
.btnPrimary:hover { .btnPrimary:hover {
background: rgba(120, 220, 200, 0.22); background: var(--color-mint-accent-bg-hover);
box-shadow: 0 0 30px rgba(120, 220, 200, 0.15); box-shadow: 0 0 30px var(--color-mint-accent-glow);
} }
.btnGhost { .btnGhost {
background: rgba(255, 255, 255, 0.05); background: var(--color-bg-glass);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid var(--color-border-card);
/* V2: 玻璃顶边白高光 */
box-shadow: inset 0 1px 0 var(--color-inset-highlight);
} }
.btnGhost .btnName { .btnGhost .btnName {
font-family: 'Space Grotesk', sans-serif; font-family: 'Space Grotesk', sans-serif;
font-size: 15px; font-size: 15px;
font-weight: 500; font-weight: 500;
color: rgba(255, 255, 255, 0.7); color: var(--color-text-on-glass);
} }
.btnGhost:hover { .btnGhost:hover {
background: rgba(255, 255, 255, 0.1); background: var(--color-bg-glass-strong);
} }
.btnGhost:hover .btnName { .btnGhost:hover .btnName {
color: rgba(255, 255, 255, 0.9); color: var(--color-text-primary);
} }
/* Sub-text below buttons */ /* Sub-text below buttons */
@ -131,14 +133,15 @@
font-family: 'Space Grotesk', sans-serif; font-family: 'Space Grotesk', sans-serif;
font-size: 12px; font-size: 12px;
font-weight: 300; font-weight: 300;
color: rgba(120, 220, 200, 0.5); color: var(--color-mint-accent);
opacity: 0.65;
} }
.btnSubGhost { .btnSubGhost {
font-family: 'Space Grotesk', sans-serif; font-family: 'Space Grotesk', sans-serif;
font-size: 12px; font-size: 12px;
font-weight: 300; font-weight: 300;
color: rgba(255, 255, 255, 0.35); color: var(--color-text-tertiary);
} }
/* ── Easter egg ── */ /* ── Easter egg ── */
@ -152,17 +155,19 @@
font-size: 13px; font-size: 13px;
font-weight: 300; font-weight: 300;
font-style: italic; font-style: italic;
color: rgba(255, 255, 255, 0.06); color: var(--color-text-quaternary);
opacity: 0.45;
letter-spacing: 0.05em; letter-spacing: 0.05em;
cursor: default; cursor: default;
transition: color 2s ease; transition: color 2s ease, opacity 2s ease;
white-space: nowrap; white-space: nowrap;
text-align: center; text-align: center;
animation: fadeUp 1.2s ease-out 0.8s both; animation: fadeUp 1.2s ease-out 0.8s both;
} }
.easter:hover { .easter:hover {
color: rgba(255, 255, 255, 0.25); color: var(--color-text-tertiary);
opacity: 1;
} }
/* ── Air Spark full-screen overlay ── */ /* ── Air Spark full-screen overlay ── */
@ -174,7 +179,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
background: rgba(0, 0, 0, 0.5); background: var(--color-overlay-soft);
backdrop-filter: blur(30px); backdrop-filter: blur(30px);
-webkit-backdrop-filter: blur(30px); -webkit-backdrop-filter: blur(30px);
animation: sparkBgIn 0.5s ease-out both; animation: sparkBgIn 0.5s ease-out both;
@ -189,7 +194,7 @@
to { to {
backdrop-filter: blur(30px); backdrop-filter: blur(30px);
-webkit-backdrop-filter: blur(30px); -webkit-backdrop-filter: blur(30px);
background: rgba(0, 0, 0, 0.5); background: var(--color-overlay-soft);
} }
} }
@ -215,7 +220,7 @@
font-family: 'Space Grotesk', sans-serif; font-family: 'Space Grotesk', sans-serif;
font-size: clamp(40px, 5vw, 64px); font-size: clamp(40px, 5vw, 64px);
font-weight: 300; font-weight: 300;
color: #ffffff; color: var(--color-text-primary);
line-height: 1.2; line-height: 1.2;
text-align: center; text-align: center;
} }
@ -229,7 +234,7 @@
font-family: 'Space Grotesk', sans-serif; font-family: 'Space Grotesk', sans-serif;
font-size: 16px; font-size: 16px;
font-weight: 300; font-weight: 300;
color: rgba(255, 255, 255, 0.5); color: var(--color-text-secondary);
margin-top: 20px; margin-top: 20px;
} }
@ -253,14 +258,14 @@
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
color: rgba(255, 255, 255, 0.2); color: var(--color-text-quaternary);
cursor: pointer; cursor: pointer;
transition: color 0.3s; transition: color 0.3s;
animation: fadeUp 1.2s ease-out 0.8s both; animation: fadeUp 1.2s ease-out 0.8s both;
} }
.musicBtn:hover { .musicBtn:hover {
color: rgba(255, 255, 255, 0.5); color: var(--color-text-tertiary);
} }

View File

@ -111,6 +111,8 @@ export function LandingPage({ autoLogin }: Props) {
}, [playing]); }, [playing]);
return ( return (
// V2: 跟随全局主题切换。LandingPage 浅色化 = AuroraCanvas + LoginModal 都跟随。
// 薄荷绿在浅色下加深为 teal (#0d9488),保证对比度。
<div className={styles.page}> <div className={styles.page}>
{/* Layer 1-4: Aurora background */} {/* Layer 1-4: Aurora background */}
<AuroraCanvas /> <AuroraCanvas />

View File

@ -8,7 +8,7 @@
} }
.searchInput:focus { border-color: var(--color-primary); } .searchInput:focus { border-color: var(--color-primary); }
.dateSep { color: var(--color-text-secondary); font-size: 13px; } .dateSep { color: var(--color-text-secondary); font-size: 13px; }
.searchBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: #fff; font-size: 13px; cursor: pointer; } .searchBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: var(--color-on-primary); font-size: 13px; cursor: pointer; }
.searchBtn:hover { opacity: 0.9; } .searchBtn:hover { opacity: 0.9; }
.refreshBtn { .refreshBtn {
padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s; padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s;
@ -19,16 +19,17 @@
.tableWrapper { .tableWrapper {
background: var(--color-bg-card); border: 1px solid var(--color-border-card); background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); overflow-x: auto; border-radius: var(--radius-card); overflow-x: auto;
box-shadow: var(--shadow-card-light);
} }
.table { width: 100%; border-collapse: collapse; font-size: 13px; max-width: none; } .table { width: 100%; border-collapse: collapse; font-size: 13px; max-width: none; }
.table th { padding: 12px 16px; text-align: left; color: var(--color-text-secondary); font-weight: 500; border-bottom: 1px solid var(--color-border-card); white-space: nowrap; } .table th { padding: 12px 16px; text-align: left; color: var(--color-text-secondary); font-weight: 500; border-bottom: 1px solid var(--color-border-card); white-space: nowrap; }
.table td { padding: 12px 16px; color: var(--color-text-primary); border-bottom: 1px solid rgba(42, 42, 56, 0.5); white-space: nowrap; } .table td { padding: 12px 16px; color: var(--color-text-primary); border-bottom: 1px solid var(--color-border-row); white-space: nowrap; }
.table tr:last-child td { border-bottom: none; } .table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: rgba(255, 255, 255, 0.02); } .table tr:hover td { background: var(--color-border-row); }
.timeCell { white-space: nowrap; font-size: 12px; color: var(--color-text-secondary); } .timeCell { white-space: nowrap; font-size: 12px; color: var(--color-text-secondary); }
.ipCell { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--color-text-secondary); white-space: nowrap; } .ipCell { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--color-text-secondary); white-space: nowrap; }
.sourceBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; background: rgba(0, 184, 230, 0.12); color: var(--color-primary); white-space: nowrap; } .sourceBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; background: var(--color-info-bg-soft); color: var(--color-primary); white-space: nowrap; }
.empty { text-align: center; color: var(--color-text-secondary); padding: 40px; } .empty { text-align: center; color: var(--color-text-secondary); padding: 40px; }
.skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; } .skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; }
@ -43,4 +44,4 @@
} }
.pageButtons button:hover:not(:disabled) { background: var(--color-sidebar-hover); color: var(--color-text-primary); } .pageButtons button:hover:not(:disabled) { background: var(--color-sidebar-hover); color: var(--color-text-primary); }
.pageButtons button:disabled { opacity: 0.4; cursor: not-allowed; } .pageButtons button:disabled { opacity: 0.4; cursor: not-allowed; }
.activePage { background: var(--color-primary) !important; color: #fff !important; border-color: var(--color-primary) !important; } .activePage { background: var(--color-primary) !important; color: var(--color-on-primary) !important; border-color: var(--color-primary) !important; }

View File

@ -85,8 +85,8 @@
/* Warning banners */ /* Warning banners */
.warningBanner { .warningBanner {
padding: 10px 16px; padding: 10px 16px;
background: rgba(243, 156, 18, 0.12); background: var(--color-warning-bg);
border: 1px solid rgba(243, 156, 18, 0.3); border: 1px solid var(--color-warning-border);
border-radius: var(--radius-card); border-radius: var(--radius-card);
color: var(--color-warning); color: var(--color-warning);
font-size: 13px; font-size: 13px;
@ -96,8 +96,8 @@
.dangerBanner { .dangerBanner {
padding: 10px 16px; padding: 10px 16px;
background: rgba(231, 76, 60, 0.12); background: var(--color-danger-bg);
border: 1px solid rgba(231, 76, 60, 0.3); border: 1px solid var(--color-danger-border);
border-radius: var(--radius-card); border-radius: var(--radius-card);
color: var(--color-danger); color: var(--color-danger);
font-size: 13px; font-size: 13px;
@ -135,6 +135,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
box-shadow: var(--shadow-card-light);
} }
.quotaLabel { .quotaLabel {
@ -153,7 +154,7 @@
.progressBar { .progressBar {
width: 100%; width: 100%;
height: 6px; height: 6px;
background: #1e1e2a; background: var(--color-bg-placeholder);
border-radius: 3px; border-radius: 3px;
overflow: hidden; overflow: hidden;
} }
@ -212,7 +213,7 @@
.tabActive { .tabActive {
background: var(--color-primary); background: var(--color-primary);
color: #fff; color: var(--color-on-primary);
} }
.sparklineWrapper { .sparklineWrapper {
@ -220,6 +221,7 @@
border: 1px solid var(--color-border-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); border-radius: var(--radius-card);
padding: 16px; padding: 16px;
box-shadow: var(--shadow-card-light);
} }
/* Records */ /* Records */
@ -245,10 +247,11 @@
border: 1px solid var(--color-border-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); border-radius: var(--radius-card);
transition: border-color 0.2s; transition: border-color 0.2s;
box-shadow: var(--shadow-card-light);
} }
.recordItem:hover { .recordItem:hover {
border-color: rgba(255, 255, 255, 0.1); border-color: var(--color-border-card);
} }
.recordLeft { .recordLeft {
@ -290,7 +293,7 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 12px; font-size: 12px;
padding: 2px 8px; padding: 2px 8px;
background: rgba(255, 255, 255, 0.04); background: var(--color-bg-upload);
border-radius: 4px; border-radius: 4px;
} }
@ -302,22 +305,22 @@
.queued { .queued {
color: var(--color-text-secondary); color: var(--color-text-secondary);
background: rgba(255, 255, 255, 0.04); background: var(--color-bg-upload);
} }
.processing { .processing {
color: var(--color-primary); color: var(--color-primary);
background: rgba(0, 184, 230, 0.1); background: var(--color-info-bg-hover);
} }
.completed { .completed {
color: var(--color-success); color: var(--color-success);
background: rgba(0, 184, 148, 0.1); background: var(--color-success-bg-hover);
} }
.failed { .failed {
color: var(--color-danger); color: var(--color-danger);
background: rgba(231, 76, 60, 0.1); background: var(--color-danger-bg-hover);
} }
.loadMoreBtn { .loadMoreBtn {
@ -397,7 +400,7 @@
.modalOverlay { .modalOverlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.6); background: var(--color-modal-overlay);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -405,7 +408,7 @@
} }
.modal { .modal {
background: #16161e; background: var(--color-bg-modal-elevated);
border: 1px solid var(--color-border-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); border-radius: var(--radius-card);
padding: 24px; padding: 24px;
@ -475,7 +478,7 @@
background: var(--color-primary); background: var(--color-primary);
border: none; border: none;
border-radius: 8px; border-radius: 8px;
color: #fff; color: var(--color-on-primary);
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
} }

View File

@ -11,6 +11,8 @@ import type { ProfileOverview, AdminRecord } from '../types';
import { showToast } from '../components/Toast'; import { showToast } from '../components/Toast';
import styles from './ProfilePage.module.css'; import styles from './ProfilePage.module.css';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { c } from '../lib/themeColor';
import { useThemeStore } from '../store/theme';
echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]); echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]);
@ -31,6 +33,7 @@ export function ProfilePage() {
const [confirmPw, setConfirmPw] = useState(''); const [confirmPw, setConfirmPw] = useState('');
const [pwError, setPwError] = useState(''); const [pwError, setPwError] = useState('');
const [pwSaving, setPwSaving] = useState(false); const [pwSaving, setPwSaving] = useState(false);
const theme = useThemeStore((s) => s.theme);
const fetchOverview = useCallback(async () => { const fetchOverview = useCallback(async () => {
try { try {
@ -110,9 +113,9 @@ export function ProfilePage() {
const sparklineOption: echarts.EChartsCoreOption = { const sparklineOption: echarts.EChartsCoreOption = {
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
backgroundColor: '#1e1e2a', backgroundColor: c('tooltip-bg'),
borderColor: '#2a2a38', borderColor: c('border-modal'),
textStyle: { color: '#e2e8f0', fontSize: 12 }, textStyle: { color: c('text-light'), fontSize: 12 },
}, },
grid: { left: 0, right: 0, top: 5, bottom: 0, containLabel: false }, grid: { left: 0, right: 0, top: 5, bottom: 0, containLabel: false },
xAxis: { type: 'category', show: false, data: overview.daily_trend.map((d) => d.date.slice(5)) }, xAxis: { type: 'category', show: false, data: overview.daily_trend.map((d) => d.date.slice(5)) },
@ -122,11 +125,11 @@ export function ProfilePage() {
data: overview.daily_trend.map((d) => d.seconds), data: overview.daily_trend.map((d) => d.seconds),
smooth: true, smooth: true,
symbol: 'none', symbol: 'none',
lineStyle: { color: '#00b8e6', width: 2 }, lineStyle: { color: c('info'), width: 2 },
areaStyle: { areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(0, 184, 230, 0.3)' }, { offset: 0, color: c('chart-area-from') },
{ offset: 1, color: 'rgba(0, 184, 230, 0.02)' }, { offset: 1, color: c('chart-area-to') },
]), ]),
}, },
}], }],
@ -208,7 +211,7 @@ export function ProfilePage() {
</div> </div>
</div> </div>
<div className={styles.sparklineWrapper}> <div className={styles.sparklineWrapper}>
<ReactEChartsCore echarts={echarts} option={sparklineOption} style={{ height: 80 }} /> <ReactEChartsCore key={`sparkline-${theme}`} echarts={echarts} option={sparklineOption} style={{ height: 80 }} />
</div> </div>
</div> </div>

View File

@ -5,7 +5,7 @@
padding: 8px 16px; background: transparent; border: 1px solid var(--color-primary); padding: 8px 16px; background: transparent; border: 1px solid var(--color-primary);
border-radius: 8px; color: var(--color-primary); font-size: 13px; cursor: pointer; transition: all 0.15s; border-radius: 8px; color: var(--color-primary); font-size: 13px; cursor: pointer; transition: all 0.15s;
} }
.exportBtn:hover { background: rgba(0, 184, 230, 0.1); } .exportBtn:hover { background: var(--color-info-bg-hover); }
.filters { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; } .filters { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
.searchInput { .searchInput {
@ -23,35 +23,36 @@
padding: 8px 12px; background: var(--color-bg-card); border: 1px solid var(--color-border-card); padding: 8px 12px; background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: 8px; color: var(--color-text-primary); font-size: 13px; outline: none; border-radius: 8px; color: var(--color-text-primary); font-size: 13px; outline: none;
} }
.searchBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: #fff; font-size: 13px; cursor: pointer; } .searchBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: var(--color-on-primary); font-size: 13px; cursor: pointer; }
.searchBtn:hover { opacity: 0.9; } .searchBtn:hover { opacity: 0.9; }
.tableWrapper { .tableWrapper {
background: var(--color-bg-card); border: 1px solid var(--color-border-card); background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); overflow-x: auto; border-radius: var(--radius-card); overflow-x: auto;
box-shadow: var(--shadow-card-light);
} }
.table { width: 100%; border-collapse: collapse; font-size: 13px; } .table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th { padding: 12px 16px; text-align: left; color: var(--color-text-secondary); font-weight: 500; border-bottom: 1px solid var(--color-border-card); white-space: nowrap; } .table th { padding: 12px 16px; text-align: left; color: var(--color-text-secondary); font-weight: 500; border-bottom: 1px solid var(--color-border-card); white-space: nowrap; }
.table td { padding: 12px 16px; color: var(--color-text-primary); border-bottom: 1px solid rgba(42, 42, 56, 0.5); white-space: nowrap; } .table td { padding: 12px 16px; color: var(--color-text-primary); border-bottom: 1px solid var(--color-border-row); white-space: nowrap; }
.table tr:last-child td { border-bottom: none; } .table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: rgba(255, 255, 255, 0.02); } .table tr:hover td { background: var(--color-border-row); }
.timeCell { white-space: nowrap; font-size: 12px; color: var(--color-text-secondary); } .timeCell { white-space: nowrap; font-size: 12px; color: var(--color-text-secondary); }
.promptCell { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-text-secondary); } .promptCell { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-text-secondary); }
.secondsBadge { color: var(--color-primary); font-weight: 600; } .secondsBadge { color: var(--color-primary); font-weight: 600; }
.statusBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; } .statusBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; }
.completed { background: rgba(0, 184, 148, 0.15); color: var(--color-success); } .completed { background: var(--color-success-bg); color: var(--color-success); }
.failed { background: rgba(231, 76, 60, 0.15); color: var(--color-danger); } .failed { background: var(--color-danger-bg); color: var(--color-danger); }
.statusCell { position: relative; } .statusCell { position: relative; }
.statusCell:hover .errorTooltip { opacity: 1; visibility: visible; transform: translateY(0); } .statusCell:hover .errorTooltip { opacity: 1; visibility: visible; transform: translateY(0); }
.errorTooltip { .errorTooltip {
position: absolute; bottom: calc(100% + 4px); right: 0; transform: translateY(4px); position: absolute; bottom: calc(100% + 4px); right: 0; transform: translateY(4px);
background: #16161e; border: 1px solid var(--color-border-card); border-radius: 6px; background: var(--color-bg-modal-elevated); border: 1px solid var(--color-border-card); border-radius: 6px;
padding: 6px 10px; font-size: 12px; color: var(--color-danger); white-space: normal; padding: 6px 10px; font-size: 12px; color: var(--color-danger); white-space: normal;
max-width: 360px; width: max-content; max-width: 360px; width: max-content;
opacity: 0; visibility: hidden; transition: all 0.15s; z-index: 10; opacity: 0; visibility: hidden; transition: all 0.15s; z-index: 10;
pointer-events: none; box-shadow: 0 4px 12px rgba(0,0,0,0.3); pointer-events: none; box-shadow: 0 4px 12px var(--color-shadow-dropdown);
} }
.queued, .processing { background: rgba(0, 184, 230, 0.15); color: var(--color-primary); } .queued, .processing { background: var(--color-info-bg); color: var(--color-primary); }
.empty { text-align: center; color: var(--color-text-secondary); padding: 40px; } .empty { text-align: center; color: var(--color-text-secondary); padding: 40px; }
.skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; } .skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
@ -65,4 +66,4 @@
} }
.pageButtons button:hover:not(:disabled) { background: var(--color-sidebar-hover); color: var(--color-text-primary); } .pageButtons button:hover:not(:disabled) { background: var(--color-sidebar-hover); color: var(--color-text-primary); }
.pageButtons button:disabled { opacity: 0.4; cursor: not-allowed; } .pageButtons button:disabled { opacity: 0.4; cursor: not-allowed; }
.activePage { background: var(--color-primary) !important; color: #fff !important; border-color: var(--color-primary) !important; } .activePage { background: var(--color-primary) !important; color: var(--color-on-primary) !important; border-color: var(--color-primary) !important; }

View File

@ -6,6 +6,7 @@
.card { .card {
background: var(--color-bg-card); border: 1px solid var(--color-border-card); background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); padding: 24px; border-radius: var(--radius-card); padding: 24px;
box-shadow: var(--shadow-card-light);
} }
.cardHeader { display: flex; justify-content: space-between; align-items: flex-start; } .cardHeader { display: flex; justify-content: space-between; align-items: flex-start; }
.cardTitle { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 4px; } .cardTitle { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 4px; }
@ -24,7 +25,7 @@
.saveBtn { .saveBtn {
padding: 10px 24px; background: var(--color-primary); border: none; border-radius: 8px; padding: 10px 24px; background: var(--color-primary); border: none; border-radius: 8px;
color: #fff; font-size: 14px; cursor: pointer; transition: opacity 0.15s; color: var(--color-on-primary); font-size: 14px; cursor: pointer; transition: opacity 0.15s;
} }
.saveBtn:hover { opacity: 0.9; } .saveBtn:hover { opacity: 0.9; }
.saveBtn:disabled { opacity: 0.5; cursor: not-allowed; } .saveBtn:disabled { opacity: 0.5; cursor: not-allowed; }
@ -38,7 +39,7 @@
} }
.slider::before { .slider::before {
content: ''; position: absolute; height: 18px; width: 18px; left: 3px; bottom: 3px; content: ''; position: absolute; height: 18px; width: 18px; left: 3px; bottom: 3px;
background: #fff; border-radius: 50%; transition: 0.3s; background: var(--color-on-primary); border-radius: 50%; transition: 0.3s;
} }
.switch input:checked + .slider { background: var(--color-primary); } .switch input:checked + .slider { background: var(--color-primary); }
.switch input:checked + .slider::before { transform: translateX(20px); } .switch input:checked + .slider::before { transform: translateX(20px); }

View File

@ -297,7 +297,7 @@ export function SettingsPage() {
setTimeout(() => { ta.focus(); ta.setSelectionRange(cursorPos, cursorPos); }, 0); setTimeout(() => { ta.focus(); ta.setSelectionRange(cursorPos, cursorPos); }, 0);
}} }}
style={{ style={{
padding: '2px 8px', fontSize: 12, background: 'rgba(255,255,255,0.06)', padding: '2px 8px', fontSize: 12, background: 'var(--color-bg-card)',
border: '1px solid var(--color-border-card)', borderRadius: 4, border: '1px solid var(--color-border-card)', borderRadius: 4,
color: 'var(--color-text-secondary)', cursor: 'pointer', color: 'var(--color-text-secondary)', cursor: 'pointer',
}} }}
@ -310,7 +310,7 @@ export function SettingsPage() {
onClick={() => setPreviewAnnouncement(!previewAnnouncement)} onClick={() => setPreviewAnnouncement(!previewAnnouncement)}
style={{ style={{
padding: '2px 8px', fontSize: 12, padding: '2px 8px', fontSize: 12,
background: previewAnnouncement ? 'rgba(108,99,255,0.12)' : 'rgba(255,255,255,0.06)', background: previewAnnouncement ? 'var(--color-purple-bg)' : 'var(--color-bg-card)',
border: `1px solid ${previewAnnouncement ? 'var(--color-primary)' : 'var(--color-border-card)'}`, border: `1px solid ${previewAnnouncement ? 'var(--color-primary)' : 'var(--color-border-card)'}`,
borderRadius: 4, borderRadius: 4,
color: previewAnnouncement ? 'var(--color-primary)' : 'var(--color-text-secondary)', color: previewAnnouncement ? 'var(--color-primary)' : 'var(--color-text-secondary)',

View File

@ -7,6 +7,8 @@ import { CanvasRenderer } from 'echarts/renderers';
import { teamApi } from '../lib/api'; import { teamApi } from '../lib/api';
import type { TeamInfo, TeamStats } from '../types'; import type { TeamInfo, TeamStats } from '../types';
import { showToast } from '../components/Toast'; import { showToast } from '../components/Toast';
import { c } from '../lib/themeColor';
import { useThemeStore } from '../store/theme';
import styles from './DashboardPage.module.css'; import styles from './DashboardPage.module.css';
echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, CanvasRenderer]); echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, CanvasRenderer]);
@ -15,6 +17,7 @@ export function TeamDashboardPage() {
const [info, setInfo] = useState<(TeamInfo & { daily_member_limit_default: number; member_count: number }) | null>(null); const [info, setInfo] = useState<(TeamInfo & { daily_member_limit_default: number; member_count: number }) | null>(null);
const [stats, setStats] = useState<TeamStats | null>(null); const [stats, setStats] = useState<TeamStats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const theme = useThemeStore((s) => s.theme);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
try { try {
@ -62,9 +65,9 @@ export function TeamDashboardPage() {
const trendOption: echarts.EChartsCoreOption = { const trendOption: echarts.EChartsCoreOption = {
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
backgroundColor: 'rgba(13, 13, 26, 0.95)', backgroundColor: c('tooltip-bg'),
borderColor: 'rgba(255, 255, 255, 0.10)', borderColor: c('tooltip-border'),
textStyle: { color: '#f1f0ff', fontSize: 12 }, textStyle: { color: c('text-primary'), fontSize: 12 },
formatter: (params: unknown) => { formatter: (params: unknown) => {
const p = (params as { name: string; value: number }[])[0]; const p = (params as { name: string; value: number }[])[0];
return `${p.name}<br/>消费: ¥${p.value.toFixed(2)}`; return `${p.name}<br/>消费: ¥${p.value.toFixed(2)}`;
@ -74,27 +77,27 @@ export function TeamDashboardPage() {
xAxis: { xAxis: {
type: 'category', type: 'category',
data: stats.daily_trend.map((d) => d.date.slice(5)), data: stats.daily_trend.map((d) => d.date.slice(5)),
axisLabel: { color: '#8b8ea8', fontSize: 11 }, axisLabel: { color: c('text-secondary'), fontSize: 11 },
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } }, axisLine: { lineStyle: { color: c('chart-axis') } },
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
axisLabel: { color: '#8b8ea8', fontSize: 11 }, axisLabel: { color: c('text-secondary'), fontSize: 11 },
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } }, splitLine: { lineStyle: { color: c('chart-grid') } },
}, },
dataZoom: [{ type: 'inside', start: 0, end: 100 }], dataZoom: [{ type: 'inside', start: 0, end: 100 }],
series: [{ series: [{
type: 'line', type: 'line',
data: stats.daily_trend.map((d) => d.cost ?? d.seconds), data: stats.daily_trend.map((d) => d.cost ?? d.seconds),
smooth: true, smooth: true,
lineStyle: { color: '#6c63ff', width: 2 }, lineStyle: { color: c('primary'), width: 2 },
areaStyle: { areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(108, 99, 255, 0.25)' }, { offset: 0, color: c('chart-area-from') },
{ offset: 1, color: 'rgba(108, 99, 255, 0.02)' }, { offset: 1, color: c('chart-area-to') },
]), ]),
}, },
itemStyle: { color: '#6c63ff' }, itemStyle: { color: c('primary') },
}], }],
}; };
@ -103,21 +106,21 @@ export function TeamDashboardPage() {
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
axisPointer: { type: 'shadow' }, axisPointer: { type: 'shadow' },
backgroundColor: 'rgba(13, 13, 26, 0.95)', backgroundColor: c('tooltip-bg'),
borderColor: 'rgba(255, 255, 255, 0.10)', borderColor: c('tooltip-border'),
textStyle: { color: '#f1f0ff', fontSize: 12 }, textStyle: { color: c('text-primary'), fontSize: 12 },
}, },
grid: { left: 80, right: 40, top: 10, bottom: 20 }, grid: { left: 80, right: 40, top: 10, bottom: 20 },
xAxis: { xAxis: {
type: 'value', type: 'value',
axisLabel: { color: '#8b8ea8', fontSize: 11 }, axisLabel: { color: c('text-secondary'), fontSize: 11 },
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } }, splitLine: { lineStyle: { color: c('chart-grid') } },
}, },
yAxis: { yAxis: {
type: 'category', type: 'category',
data: sortedMembers.map((m) => m.username), data: sortedMembers.map((m) => m.username),
axisLabel: { color: '#8b8ea8', fontSize: 12 }, axisLabel: { color: c('text-secondary'), fontSize: 12 },
axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } }, axisLine: { lineStyle: { color: c('chart-axis') } },
}, },
series: [{ series: [{
type: 'bar', type: 'bar',
@ -125,15 +128,15 @@ export function TeamDashboardPage() {
barWidth: 16, barWidth: 16,
itemStyle: { itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [ color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#6c63ff' }, { offset: 0, color: c('primary') },
{ offset: 1, color: '#8b5cf6' }, { offset: 1, color: c('primary-2') },
]), ]),
borderRadius: [0, 4, 4, 0], borderRadius: [0, 4, 4, 0],
}, },
label: { label: {
show: true, show: true,
position: 'right', position: 'right',
color: '#8b8ea8', color: c('text-secondary'),
fontSize: 11, fontSize: 11,
formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`, formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`,
}, },
@ -156,14 +159,14 @@ export function TeamDashboardPage() {
<div className={styles.chartSection}> <div className={styles.chartSection}>
<h2 className={styles.sectionTitle}>30 · </h2> <h2 className={styles.sectionTitle}>30 · </h2>
<div className={styles.chartWrapper}> <div className={styles.chartWrapper}>
<ReactEChartsCore echarts={echarts} option={trendOption} style={{ height: 320 }} /> <ReactEChartsCore key={`trend-${theme}`} echarts={echarts} option={trendOption} style={{ height: 320 }} />
</div> </div>
</div> </div>
<div className={styles.chartSection}> <div className={styles.chartSection}>
<h2 className={styles.sectionTitle}></h2> <h2 className={styles.sectionTitle}></h2>
<div className={styles.chartWrapper}> <div className={styles.chartWrapper}>
<ReactEChartsCore echarts={echarts} option={barOption} style={{ height: Math.max(300, sortedMembers.length * 36) }} /> <ReactEChartsCore key={`members-${theme}`} echarts={echarts} option={barOption} style={{ height: Math.max(300, sortedMembers.length * 36) }} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -146,16 +146,16 @@ export function TeamMembersPage() {
<td> <td>
<span style={{ <span style={{
display: 'inline-block', width: 8, height: 8, borderRadius: '50%', display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
background: m.is_online ? '#00b894' : '#555', marginRight: 6, background: m.is_online ? 'var(--color-success)' : 'var(--color-text-quaternary)', marginRight: 6,
verticalAlign: 'middle', verticalAlign: 'middle',
}} /> }} />
{m.username} {m.username}
</td> </td>
<td> <td>
{m.is_team_owner ? ( {m.is_team_owner ? (
<span className={styles.statusBadge} style={{ background: 'rgba(0, 184, 230, 0.15)', color: '#00b8e6' }}></span> <span className={styles.statusBadge} style={{ background: 'var(--color-info-bg)', color: 'var(--color-info)' }}></span>
) : m.is_team_admin ? ( ) : m.is_team_admin ? (
<span className={styles.statusBadge} style={{ background: 'rgba(167, 139, 250, 0.15)', color: '#a78bfa' }}></span> <span className={styles.statusBadge} style={{ background: 'var(--color-purple-bg)', color: 'var(--color-purple-accent)' }}></span>
) : ( ) : (
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}></span> <span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}></span>
)} )}

View File

@ -13,34 +13,35 @@
background: transparent; border: 1px solid var(--color-border-card); color: var(--color-text-secondary); background: transparent; border: 1px solid var(--color-border-card); color: var(--color-text-secondary);
} }
.refreshBtn:hover { background: var(--color-sidebar-hover); } .refreshBtn:hover { background: var(--color-sidebar-hover); }
.createBtn { padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s; background: var(--color-success); border: none; color: #fff; font-weight: 500; } .createBtn { padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s; background: var(--color-success); border: none; color: var(--color-on-primary); font-weight: 500; }
.createBtn:hover { opacity: 0.9; } .createBtn:hover { opacity: 0.9; }
.tableWrapper { .tableWrapper {
background: var(--color-bg-card); border: 1px solid var(--color-border-card); background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); overflow-x: auto; border-radius: var(--radius-card); overflow-x: auto;
box-shadow: var(--shadow-card-light);
} }
.table { width: 100%; border-collapse: collapse; font-size: 13px; } .table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th { padding: 12px 16px; text-align: left; color: var(--color-text-secondary); font-weight: 500; border-bottom: 1px solid var(--color-border-card); white-space: nowrap; } .table th { padding: 12px 16px; text-align: left; color: var(--color-text-secondary); font-weight: 500; border-bottom: 1px solid var(--color-border-card); white-space: nowrap; }
.table td { padding: 12px 16px; color: var(--color-text-primary); border-bottom: 1px solid rgba(42, 42, 56, 0.5); white-space: nowrap; } .table td { padding: 12px 16px; color: var(--color-text-primary); border-bottom: 1px solid var(--color-border-row); white-space: nowrap; }
.table tr:last-child td { border-bottom: none; } .table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: rgba(255, 255, 255, 0.02); } .table tr:hover td { background: var(--color-bg-row-hover); }
.teamNameLink { background: none; border: none; color: var(--color-primary); cursor: pointer; font-size: 13px; text-decoration: underline; } .teamNameLink { background: none; border: none; color: var(--color-primary); cursor: pointer; font-size: 13px; text-decoration: underline; }
.statusBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; } .statusBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; }
.active { background: rgba(0, 184, 148, 0.15); color: var(--color-success); } .active { background: var(--color-success-bg); color: var(--color-success); }
.disabled { background: rgba(231, 76, 60, 0.15); color: var(--color-danger); } .disabled { background: var(--color-danger-bg); color: var(--color-danger); }
.actions { display: flex; gap: 6px; } .actions { display: flex; gap: 6px; }
.editBtn, .toggleBtn, .topupBtn, .adminBtn { padding: 4px 10px; border-radius: 6px; font-size: 12px; cursor: pointer; transition: all 0.15s; } .editBtn, .toggleBtn, .topupBtn, .adminBtn { padding: 4px 10px; border-radius: 6px; font-size: 12px; cursor: pointer; transition: all 0.15s; }
.topupBtn { background: transparent; border: 1px solid var(--color-primary); color: var(--color-primary); } .topupBtn { background: transparent; border: 1px solid var(--color-primary); color: var(--color-primary); }
.topupBtn:hover { background: rgba(0, 184, 230, 0.1); } .topupBtn:hover { background: var(--color-info-bg-hover); }
.adminBtn { background: transparent; border: 1px solid #a78bfa; color: #a78bfa; } .adminBtn { background: transparent; border: 1px solid var(--color-purple-accent); color: var(--color-purple-accent); }
.adminBtn:hover { background: rgba(167, 139, 250, 0.1); } .adminBtn:hover { background: var(--color-purple-bg-hover); }
.disableBtn { background: transparent; border: 1px solid var(--color-danger); color: var(--color-danger); } .disableBtn { background: transparent; border: 1px solid var(--color-danger); color: var(--color-danger); }
.disableBtn:hover { background: rgba(231, 76, 60, 0.1); } .disableBtn:hover { background: var(--color-danger-bg-hover); }
.enableBtn { background: transparent; border: 1px solid var(--color-success); color: var(--color-success); } .enableBtn { background: transparent; border: 1px solid var(--color-success); color: var(--color-success); }
.enableBtn:hover { background: rgba(0, 184, 148, 0.1); } .enableBtn:hover { background: var(--color-success-bg-hover); }
.empty { text-align: center; color: var(--color-text-secondary); padding: 40px; } .empty { text-align: center; color: var(--color-text-secondary); padding: 40px; }
.skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; } .skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; }
@ -50,8 +51,8 @@
.secondsSub { color: var(--color-text-secondary); font-size: 11px; margin-left: 4px; } .secondsSub { color: var(--color-text-secondary); font-size: 11px; margin-left: 4px; }
/* Modal */ /* Modal */
.modalOverlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 300; } .modalOverlay { position: fixed; inset: 0; background: var(--color-modal-overlay); display: flex; align-items: center; justify-content: center; z-index: 300; }
.modal { background: #16161e; border: 1px solid var(--color-border-card); border-radius: var(--radius-card); padding: 24px; width: 400px; max-width: 90vw; } .modal { background: var(--color-bg-modal-elevated); border: 1px solid var(--color-border-card); border-radius: var(--radius-card); padding: 24px; width: 400px; max-width: 90vw; }
.modalTitle { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 20px; } .modalTitle { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 20px; }
.formGroup { margin-bottom: 16px; } .formGroup { margin-bottom: 16px; }
.formGroup label { display: block; color: var(--color-text-secondary); font-size: 13px; margin-bottom: 6px; } .formGroup label { display: block; color: var(--color-text-secondary); font-size: 13px; margin-bottom: 6px; }
@ -59,7 +60,7 @@
.formGroup input:focus { border-color: var(--color-primary); } .formGroup input:focus { border-color: var(--color-primary); }
.modalActions { display: flex; justify-content: flex-end; gap: 8px; } .modalActions { display: flex; justify-content: flex-end; gap: 8px; }
.cancelBtn { padding: 8px 16px; background: transparent; border: 1px solid var(--color-border-card); border-radius: 8px; color: var(--color-text-secondary); font-size: 13px; cursor: pointer; } .cancelBtn { padding: 8px 16px; background: transparent; border: 1px solid var(--color-border-card); border-radius: 8px; color: var(--color-text-secondary); font-size: 13px; cursor: pointer; }
.saveBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: #fff; font-size: 13px; cursor: pointer; } .saveBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: var(--color-on-primary); font-size: 13px; cursor: pointer; }
.formRow { display: flex; gap: 12px; } .formRow { display: flex; gap: 12px; }
.formRow .formGroup { flex: 1; } .formRow .formGroup { flex: 1; }
.formError { color: var(--color-danger); font-size: 13px; margin-bottom: 12px; } .formError { color: var(--color-danger); font-size: 13px; margin-bottom: 12px; }
@ -71,7 +72,7 @@
.detailOverlay { .detailOverlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.65); background: var(--color-modal-overlay);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -85,10 +86,10 @@
} }
.detailModal { .detailModal {
background: rgba(22, 22, 30, 0.92); background: var(--color-bg-modal-glass);
backdrop-filter: blur(24px) saturate(180%); backdrop-filter: var(--bf-glass-lg);
-webkit-backdrop-filter: blur(24px) saturate(180%); -webkit-backdrop-filter: var(--bf-glass-lg);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid var(--color-border-modal-soft);
border-radius: 16px; border-radius: 16px;
width: 1280px; width: 1280px;
max-width: 96vw; max-width: 96vw;
@ -96,7 +97,7 @@
max-height: 90vh; max-height: 90vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.04) inset; box-shadow: 0 24px 64px var(--color-shadow-modal), var(--shadow-glass-light);
animation: modalIn 0.25s ease; animation: modalIn 0.25s ease;
} }
@ -111,7 +112,7 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 20px 28px; padding: 20px 28px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06); border-bottom: 1px solid var(--color-border-soft);
flex-shrink: 0; flex-shrink: 0;
} }
@ -132,16 +133,16 @@
width: 36px; width: 36px;
height: 36px; height: 36px;
border-radius: 8px; border-radius: 8px;
background: rgba(255, 255, 255, 0.06); background: var(--color-bg-card);
border: none; border: none;
color: rgba(255, 255, 255, 0.5); color: var(--color-text-on-glass-soft);
cursor: pointer; cursor: pointer;
transition: color 0.15s, background 0.15s; transition: color 0.15s, background 0.15s;
} }
.detailClose:hover { .detailClose:hover {
color: #fff; color: var(--color-text-primary);
background: rgba(255, 255, 255, 0.12); background: var(--color-bg-glass-hover);
} }
/* ── Body ── */ /* ── Body ── */
@ -163,26 +164,26 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
background: rgba(255, 255, 255, 0.04); background: var(--color-bg-upload);
border: 1px solid rgba(255, 255, 255, 0.06); border: 1px solid var(--color-border-soft);
border-radius: 10px; border-radius: 10px;
padding: 16px 18px; padding: 16px 18px;
transition: background 0.15s; transition: background 0.15s;
} }
.detailItem:hover { .detailItem:hover {
background: rgba(255, 255, 255, 0.06); background: var(--color-bg-card);
} }
.detailLabel { .detailLabel {
color: #8b8ea8; color: var(--color-text-secondary);
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
line-height: 1; line-height: 1;
} }
.detailValue { .detailValue {
color: #f1f0ff; color: var(--color-text-primary);
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
display: flex; display: flex;
@ -197,9 +198,9 @@
width: 26px; width: 26px;
height: 26px; height: 26px;
border-radius: 6px; border-radius: 6px;
background: rgba(255, 255, 255, 0.06); background: var(--color-bg-card);
border: none; border: none;
color: rgba(255, 255, 255, 0.4); color: var(--color-text-on-glass-faint);
cursor: pointer; cursor: pointer;
transition: color 0.15s, background 0.15s; transition: color 0.15s, background 0.15s;
flex-shrink: 0; flex-shrink: 0;
@ -207,7 +208,7 @@
.editPoolBtn:hover { .editPoolBtn:hover {
color: var(--color-primary); color: var(--color-primary);
background: rgba(0, 184, 230, 0.12); background: var(--color-info-bg-soft);
} }
/* ── Members section ── */ /* ── Members section ── */
@ -218,12 +219,12 @@
margin-top: 20px; margin-top: 20px;
margin-bottom: 14px; margin-bottom: 14px;
padding-bottom: 12px; padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06); border-bottom: 1px solid var(--color-border-soft);
} }
.memberTableWrapper { .memberTableWrapper {
background: rgba(255, 255, 255, 0.03); background: var(--color-bg-upload);
border: 1px solid rgba(255, 255, 255, 0.06); border: 1px solid var(--color-border-soft);
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
} }
@ -237,28 +238,28 @@
.memberTable th { .memberTable th {
padding: 12px 18px; padding: 12px 18px;
text-align: left; text-align: left;
color: #8b8ea8; color: var(--color-text-secondary);
font-weight: 500; font-weight: 500;
font-size: 13px; font-size: 13px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06); border-bottom: 1px solid var(--color-border-soft);
background: rgba(255, 255, 255, 0.02); background: var(--color-bg-row-hover);
white-space: nowrap; white-space: nowrap;
} }
.memberTable td { .memberTable td {
padding: 14px 18px; padding: 14px 18px;
color: #f1f0ff; color: var(--color-text-primary);
border-bottom: 1px solid rgba(255, 255, 255, 0.04); border-bottom: 1px solid var(--color-border-row);
} }
.memberTable tr:last-child td { border-bottom: none; } .memberTable tr:last-child td { border-bottom: none; }
.memberTable tr:hover td { .memberTable tr:hover td {
background: rgba(255, 255, 255, 0.04); background: var(--color-bg-upload);
} }
.ownerBadge { .ownerBadge {
background: rgba(0, 184, 230, 0.15); background: var(--color-info-bg);
color: var(--color-primary, #00b8e6); color: var(--color-primary, #00b8e6);
padding: 3px 10px; padding: 3px 10px;
border-radius: 6px; border-radius: 6px;
@ -267,8 +268,8 @@
} }
.adminBadge { .adminBadge {
background: rgba(167, 139, 250, 0.15); background: var(--color-purple-bg);
color: #a78bfa; color: var(--color-purple-accent);
padding: 3px 10px; padding: 3px 10px;
border-radius: 6px; border-radius: 6px;
font-size: 12px; font-size: 12px;

View File

@ -744,7 +744,7 @@ export function TeamsPage() {
<button <button
key={val} key={val}
onClick={() => setAnomalyConfigDraft({ ...anomalyConfigDraft, [item.key]: val === '' ? null : val === 'true' })} onClick={() => setAnomalyConfigDraft({ ...anomalyConfigDraft, [item.key]: val === '' ? null : val === 'true' })}
style={{ flex: 1, padding: '3px 0', fontSize: 11, border: 'none', cursor: 'pointer', background: selected ? 'var(--color-primary)' : 'var(--color-bg-card)', color: selected ? '#fff' : 'var(--color-text-secondary)', transition: 'all 0.15s' }} style={{ flex: 1, padding: '3px 0', fontSize: 11, border: 'none', cursor: 'pointer', background: selected ? 'var(--color-primary)' : 'var(--color-bg-card)', color: selected ? 'var(--color-on-primary)' : 'var(--color-text-secondary)', transition: 'all 0.15s' }}
> >
{label} {label}
</button> </button>
@ -815,7 +815,7 @@ export function TeamsPage() {
<td> <td>
<span style={{ <span style={{
display: 'inline-block', width: 8, height: 8, borderRadius: '50%', display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
background: m.is_online ? '#00b894' : '#555', marginRight: 6, background: m.is_online ? 'var(--color-success)' : 'var(--color-text-quaternary)', marginRight: 6,
verticalAlign: 'middle', verticalAlign: 'middle',
}} /> }} />
{m.username} {m.username}

View File

@ -15,36 +15,37 @@
.searchBtn, .refreshBtn { .searchBtn, .refreshBtn {
padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s; padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s;
} }
.searchBtn { background: var(--color-primary); border: none; color: #fff; } .searchBtn { background: var(--color-primary); border: none; color: var(--color-on-primary); }
.searchBtn:hover { opacity: 0.9; } .searchBtn:hover { opacity: 0.9; }
.refreshBtn { background: transparent; border: 1px solid var(--color-border-card); color: var(--color-text-secondary); } .refreshBtn { background: transparent; border: 1px solid var(--color-border-card); color: var(--color-text-secondary); }
.refreshBtn:hover { background: var(--color-sidebar-hover); } .refreshBtn:hover { background: var(--color-sidebar-hover); }
.createBtn { padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s; background: var(--color-success); border: none; color: #fff; font-weight: 500; } .createBtn { padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s; background: var(--color-success); border: none; color: var(--color-on-primary); font-weight: 500; }
.createBtn:hover { opacity: 0.9; } .createBtn:hover { opacity: 0.9; }
.tableWrapper { .tableWrapper {
background: var(--color-bg-card); border: 1px solid var(--color-border-card); background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); overflow-x: auto; border-radius: var(--radius-card); overflow-x: auto;
box-shadow: var(--shadow-card-light);
} }
.table { width: 100%; border-collapse: collapse; font-size: 13px; } .table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th { padding: 12px 16px; text-align: left; color: var(--color-text-secondary); font-weight: 500; border-bottom: 1px solid var(--color-border-card); white-space: nowrap; } .table th { padding: 12px 16px; text-align: left; color: var(--color-text-secondary); font-weight: 500; border-bottom: 1px solid var(--color-border-card); white-space: nowrap; }
.table td { padding: 12px 16px; color: var(--color-text-primary); border-bottom: 1px solid rgba(42, 42, 56, 0.5); white-space: nowrap; } .table td { padding: 12px 16px; color: var(--color-text-primary); border-bottom: 1px solid var(--color-border-row); white-space: nowrap; }
.table tr:last-child td { border-bottom: none; } .table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: rgba(255, 255, 255, 0.02); } .table tr:hover td { background: var(--color-bg-row-hover); }
.usernameLink { background: none; border: none; color: var(--color-primary); cursor: pointer; font-size: 13px; text-decoration: underline; } .usernameLink { background: none; border: none; color: var(--color-primary); cursor: pointer; font-size: 13px; text-decoration: underline; }
.statusBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; } .statusBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; }
.active { background: rgba(0, 184, 148, 0.15); color: var(--color-success); } .active { background: var(--color-success-bg); color: var(--color-success); }
.disabled { background: rgba(231, 76, 60, 0.15); color: var(--color-danger); } .disabled { background: var(--color-danger-bg); color: var(--color-danger); }
.actions { display: flex; gap: 6px; } .actions { display: flex; gap: 6px; }
.editBtn, .toggleBtn { padding: 4px 10px; border-radius: 6px; font-size: 12px; cursor: pointer; transition: all 0.15s; } .editBtn, .toggleBtn { padding: 4px 10px; border-radius: 6px; font-size: 12px; cursor: pointer; transition: all 0.15s; }
.editBtn { background: transparent; border: 1px solid var(--color-primary); color: var(--color-primary); } .editBtn { background: transparent; border: 1px solid var(--color-primary); color: var(--color-primary); }
.editBtn:hover { background: rgba(0, 184, 230, 0.1); } .editBtn:hover { background: var(--color-info-bg-hover); }
.disableBtn { background: transparent; border: 1px solid var(--color-danger); color: var(--color-danger); } .disableBtn { background: transparent; border: 1px solid var(--color-danger); color: var(--color-danger); }
.disableBtn:hover { background: rgba(231, 76, 60, 0.1); } .disableBtn:hover { background: var(--color-danger-bg-hover); }
.enableBtn { background: transparent; border: 1px solid var(--color-success); color: var(--color-success); } .enableBtn { background: transparent; border: 1px solid var(--color-success); color: var(--color-success); }
.enableBtn:hover { background: rgba(0, 184, 148, 0.1); } .enableBtn:hover { background: var(--color-success-bg-hover); }
.empty { text-align: center; color: var(--color-text-secondary); padding: 40px; } .empty { text-align: center; color: var(--color-text-secondary); padding: 40px; }
.skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; } .skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; }
@ -59,11 +60,11 @@
} }
.pageButtons button:hover:not(:disabled) { background: var(--color-sidebar-hover); color: var(--color-text-primary); } .pageButtons button:hover:not(:disabled) { background: var(--color-sidebar-hover); color: var(--color-text-primary); }
.pageButtons button:disabled { opacity: 0.4; cursor: not-allowed; } .pageButtons button:disabled { opacity: 0.4; cursor: not-allowed; }
.activePage { background: var(--color-primary) !important; color: #fff !important; border-color: var(--color-primary) !important; } .activePage { background: var(--color-primary) !important; color: var(--color-on-primary) !important; border-color: var(--color-primary) !important; }
/* Modal */ /* Modal */
.modalOverlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 300; } .modalOverlay { position: fixed; inset: 0; background: var(--color-modal-overlay); display: flex; align-items: center; justify-content: center; z-index: 300; }
.modal { background: #16161e; border: 1px solid var(--color-border-card); border-radius: var(--radius-card); padding: 24px; width: 400px; max-width: 90vw; } .modal { background: var(--color-bg-modal-elevated); border: 1px solid var(--color-border-card); border-radius: var(--radius-card); padding: 24px; width: 400px; max-width: 90vw; }
.modalTitle { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 20px; } .modalTitle { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 20px; }
.formGroup { margin-bottom: 16px; } .formGroup { margin-bottom: 16px; }
.formGroup label { display: block; color: var(--color-text-secondary); font-size: 13px; margin-bottom: 6px; } .formGroup label { display: block; color: var(--color-text-secondary); font-size: 13px; margin-bottom: 6px; }
@ -71,7 +72,7 @@
.formGroup input:focus { border-color: var(--color-primary); } .formGroup input:focus { border-color: var(--color-primary); }
.modalActions { display: flex; justify-content: flex-end; gap: 8px; } .modalActions { display: flex; justify-content: flex-end; gap: 8px; }
.cancelBtn { padding: 8px 16px; background: transparent; border: 1px solid var(--color-border-card); border-radius: 8px; color: var(--color-text-secondary); font-size: 13px; cursor: pointer; } .cancelBtn { padding: 8px 16px; background: transparent; border: 1px solid var(--color-border-card); border-radius: 8px; color: var(--color-text-secondary); font-size: 13px; cursor: pointer; }
.saveBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: #fff; font-size: 13px; cursor: pointer; } .saveBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: var(--color-on-primary); font-size: 13px; cursor: pointer; }
.formRow { display: flex; gap: 12px; } .formRow { display: flex; gap: 12px; }
.formRow .formGroup { flex: 1; } .formRow .formGroup { flex: 1; }
.checkboxLabel { display: flex; align-items: center; gap: 8px; cursor: pointer; color: var(--color-text-primary); font-size: 13px; } .checkboxLabel { display: flex; align-items: center; gap: 8px; cursor: pointer; color: var(--color-text-primary); font-size: 13px; }
@ -79,10 +80,10 @@
.formError { color: var(--color-danger); font-size: 13px; margin-bottom: 12px; } .formError { color: var(--color-danger); font-size: 13px; margin-bottom: 12px; }
/* Drawer */ /* Drawer */
.drawerOverlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 300; } .drawerOverlay { position: fixed; inset: 0; background: var(--color-overlay-soft); z-index: 300; }
.drawer { .drawer {
position: fixed; right: 0; top: 0; bottom: 0; width: 440px; max-width: 90vw; position: fixed; right: 0; top: 0; bottom: 0; width: 440px; max-width: 90vw;
background: #16161e; border-left: 1px solid var(--color-border-card); background: var(--color-bg-modal-elevated); border-left: 1px solid var(--color-border-card);
display: flex; flex-direction: column; z-index: 301; display: flex; flex-direction: column; z-index: 301;
animation: slideIn 0.2s ease; animation: slideIn 0.2s ease;
} }
@ -103,7 +104,7 @@
.recordSeconds { color: var(--color-primary); font-weight: 600; font-size: 14px; } .recordSeconds { color: var(--color-primary); font-weight: 600; font-size: 14px; }
.recordMode { color: var(--color-text-secondary); font-size: 12px; } .recordMode { color: var(--color-text-secondary); font-size: 12px; }
.recordStatus { font-size: 12px; padding: 1px 6px; border-radius: 4px; } .recordStatus { font-size: 12px; padding: 1px 6px; border-radius: 4px; }
.completed { background: rgba(0, 184, 148, 0.15); color: var(--color-success); } .completed { background: var(--color-success-bg); color: var(--color-success); }
.failed { background: rgba(231, 76, 60, 0.15); color: var(--color-danger); } .failed { background: var(--color-danger-bg); color: var(--color-danger); }
.queued, .processing { background: rgba(0, 184, 230, 0.15); color: var(--color-primary); } .queued, .processing { background: var(--color-info-bg); color: var(--color-primary); }
.recordPrompt { color: var(--color-text-secondary); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .recordPrompt { color: var(--color-text-secondary); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

53
web/src/store/theme.ts Normal file
View File

@ -0,0 +1,53 @@
import { create } from 'zustand';
export type Theme = 'dark' | 'light';
interface ThemeState {
theme: Theme;
toggleTheme: () => void;
setTheme: (t: Theme) => void;
}
const STORAGE_KEY = 'airdrama-theme';
const readInitialTheme = (): Theme => {
if (typeof window === 'undefined') return 'dark';
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark') return stored;
} catch {
// localStorage unavailable (incognito / disabled) — fall through to default
}
return 'dark';
};
const applyThemeToDom = (t: Theme): void => {
if (typeof document === 'undefined') return;
document.documentElement.dataset.theme = t;
};
const persistTheme = (t: Theme): void => {
try {
window.localStorage.setItem(STORAGE_KEY, t);
} catch {
// localStorage unavailable — silent fallback (theme still applies for the session)
}
};
const initial = readInitialTheme();
applyThemeToDom(initial);
export const useThemeStore = create<ThemeState>((set, get) => ({
theme: initial,
toggleTheme: () => {
const next: Theme = get().theme === 'dark' ? 'light' : 'dark';
applyThemeToDom(next);
persistTheme(next);
set({ theme: next });
},
setTheme: (t) => {
applyThemeToDom(t);
persistTheme(t);
set({ theme: t });
},
}));

View File

@ -217,6 +217,7 @@ export interface AdminRecord {
duration?: number; duration?: number;
seed?: number; seed?: number;
ark_task_id?: string; ark_task_id?: string;
result_url?: string;
} }
export interface SystemSettings { export interface SystemSettings {

View File

@ -0,0 +1,158 @@
/**
* 专项验证 RecordDetailModal 内部交互(改完排版后回归):
* - 点击参考素材 thumb lightbox
* - 点击 thumb 内下载按钮不触发 lightbox(stopPropagation)
* - hover thumb title 属性带完整 label
* - 无参考素材时左侧只有视频不崩
* - max-height 滚动(模拟 8+ refs - :实测数据可能没这么多,只验逻辑)
*/
import { chromium } from '@playwright/test';
const BASE = 'http://localhost:5173';
const API = 'http://localhost:8000';
const results = [];
function pass(name) { results.push({ name, ok: true }); console.log(`${name}`); }
function fail(name, err) { results.push({ name, ok: false, err: err?.message || err }); console.log(`${name}: ${err?.message || err}`); }
async function loginAdmin(page) {
const res = await page.request.post(`${API}/api/v1/auth/login`, {
data: { username: 'admin', password: 'admin123' },
});
const body = await res.json();
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
await page.evaluate(({ access, refresh, user }) => {
localStorage.setItem('access_token', access);
if (refresh) localStorage.setItem('refresh_token', refresh);
if (user) localStorage.setItem('user', JSON.stringify(user));
}, { access: body?.tokens?.access, refresh: body?.tokens?.refresh, user: body?.user });
}
async function main() {
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const consoleErrors = [];
page.on('console', (m) => {
if (m.type() === 'error' && !/401|404|Failed to load|DevTools/.test(m.text())) {
consoleErrors.push(m.text());
}
});
console.log('\n════ Modal interaction regression ════');
await loginAdmin(page);
// 找一个有参考素材的 completed record
await page.goto(`${BASE}/admin/records`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1500);
const completedRow = page.locator('tr').filter({ hasText: '已完成' }).first();
await completedRow.click({ force: true });
await page.waitForTimeout(1200);
// 1. modal 打开
const modalOpen = await page.locator('text=任务详情').first().isVisible();
if (modalOpen) pass('1. 弹窗打开'); else { fail('1. 弹窗打开', new Error('找不到')); return; }
// 2. 参考素材是否在(只测有 ref 的 record)
const refSection = page.locator('text=参考素材').first();
const hasRefs = await refSection.isVisible().catch(() => false);
if (!hasRefs) {
pass('2. 参考素材区 (无 refs case,跳过后续 thumb 测试)');
await browser.close();
return;
}
pass('2. 参考素材区存在');
// 3. title 属性带完整 label
const firstRefItem = page.locator('text=参考素材').locator('xpath=..').locator('div[title]').first();
const titleAttr = await firstRefItem.getAttribute('title').catch(() => null);
if (titleAttr) pass(`3. thumb 有 title 属性 ("${titleAttr.slice(0, 30)}...")`);
else fail('3. thumb title 缺失', new Error('hover tooltip 用的 title 属性没设上'));
// 4. 点击 thumb img 弹 lightbox — thumb 是参考素材区内的 img 元素(ReferenceList refImgStyle 80×80)
// lightbox 是 fixed full-screen img with max-width: 90vw
const beforeClickImgs = await page.locator('img').count();
// 找参考素材区第一个 img(thumb,80×80)
const thumbImg = page.locator('div[title]').locator('img').first();
const hasThumbImg = await thumbImg.isVisible().catch(() => false);
if (hasThumbImg) {
await thumbImg.click({ force: true });
await page.waitForTimeout(500);
const afterClickImgs = await page.locator('img').count();
if (afterClickImgs > beforeClickImgs) {
pass('4. 点击 thumb 弹出 lightbox (DOM 新增 img)');
// 4.1 验证 lightbox 关闭不连带关 modal — 用 Playwright 程序化 click on lightbox element
// (不能用 mouse.click(x,y),因为 backdrop-filter 创建独立 stacking context,
// lightbox overlay 的实际命中区域可能与视觉不一致)
const lightboxOverlay = page.locator('div[style*="z-index"]').filter({ hasText: '' }).last();
// 简单粗暴:找最后一个 fixed inset 0 div(就是 lightbox overlay)
const allOverlays = await page.locator('div').filter({
has: page.locator(':scope > img[style*="max-width: 90vw"]')
}).all();
if (allOverlays.length > 0) {
await allOverlays[0].click({ force: true, position: { x: 5, y: 5 } });
await page.waitForTimeout(400);
} else {
// 兜底:直接清 React state 通过 Esc 不太行,通过点击坐标
await page.mouse.click(20, 20);
await page.waitForTimeout(400);
}
const modalStillOpenAfterLightboxClose = await page.locator('text=任务详情').first().isVisible().catch(() => false);
if (modalStillOpenAfterLightboxClose) {
pass('4.1 关闭 lightbox 不连带关 modal (stopPropagation 链 OK)');
} else {
fail('4.1 lightbox 冒泡 bug', new Error('点 lightbox overlay 把 modal 一起关了 - 事件冒泡到 modal overlay'));
}
} else {
fail('4. 点击 thumb 弹 lightbox', new Error(`img 数 ${beforeClickImgs}${afterClickImgs},没新增`));
}
} else {
pass('4. 点击 thumb 弹 lightbox (无 img thumb,可能都是 placeholder,跳过)');
}
// 5. 下载按钮(stopPropagation,不应同时弹 lightbox)
const downloadBtn = page.locator('button[title="下载"]').first();
const hasDownloadBtn = await downloadBtn.isVisible().catch(() => false);
if (hasDownloadBtn) {
// 监听 download 事件 — Playwright 拦截下载
const downloadPromise = page.waitForEvent('download', { timeout: 3000 }).catch(() => null);
await downloadBtn.click({ force: true });
await page.waitForTimeout(500);
const download = await downloadPromise;
// lightbox 不应该被同时打开
const lightboxOpenAfterDl = await page.locator('div').filter({ has: page.locator('img[src][alt=""]') }).filter({
has: page.locator(':scope > img[style*="max-width: 90vw"]')
}).count().catch(() => 0);
if (lightboxOpenAfterDl === 0) {
pass(`5. 下载按钮 stopPropagation 生效${download ? ' + 触发下载' : ''}`);
} else {
fail('5. stopPropagation', new Error('点下载按钮同时把 lightbox 也打开了'));
}
} else {
pass('5. 下载按钮 (无 hasUrl 的 ref,跳过)');
}
// 6. 关闭 modal
await page.locator('button:has-text("✕")').first().click();
await page.waitForTimeout(400);
const modalStillOpen = await page.locator('text=任务详情').first().isVisible().catch(() => false);
if (!modalStillOpen) pass('6. modal 关闭 ✕ 按钮 OK');
else fail('6. modal 关闭', new Error('点 ✕ 后 modal 还在'));
// 7. console 无 error
if (consoleErrors.length === 0) pass('7. 全程无 console.error');
else fail('7. console errors', new Error(`${consoleErrors.length} 个:\n` + consoleErrors.slice(0, 3).join('\n')));
await browser.close();
const passCount = results.filter(r => r.ok).length;
const failCount = results.filter(r => !r.ok).length;
console.log(`\n══ Pass: ${passCount} / Fail: ${failCount} ══`);
if (failCount > 0) {
results.filter(r => !r.ok).forEach(r => console.log(`${r.name}: ${r.err}`));
process.exit(1);
}
}
main().catch(e => { console.error(e); process.exit(1); });

105
web/test/modal-preview.mjs Normal file
View File

@ -0,0 +1,105 @@
/**
* 一次性脚本: RecordDetailModal 双栏新版 成功态 / 失败态 × / = 4
*/
import { chromium } from '@playwright/test';
import { mkdir } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT = resolve(__dirname, '../../docs/screenshots/v2/modal');
const BASE = 'http://localhost:5173';
const API = 'http://localhost:8000';
async function loginAdmin(page) {
const res = await page.request.post(`${API}/api/v1/auth/login`, {
data: { username: 'admin', password: 'admin123' },
});
const body = await res.json();
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
await page.evaluate(({ access, refresh, user }) => {
localStorage.setItem('access_token', access);
if (refresh) localStorage.setItem('refresh_token', refresh);
if (user) localStorage.setItem('user', JSON.stringify(user));
}, { access: body?.tokens?.access, refresh: body?.tokens?.refresh, user: body?.user });
}
async function setTheme(page, theme) {
await page.evaluate((t) => {
localStorage.setItem('airdrama-theme', t);
document.documentElement.dataset.theme = t;
}, theme);
}
async function findRowByStatus(page, statusText) {
// 表格行里有 "已完成" / "失败" 文字的 row,点 username 链接打开 modal
const rows = page.locator('tr').filter({ hasText: statusText });
const count = await rows.count();
if (count === 0) return null;
// 找一行的 username 链接(usernameLink 触发 detail modal)
return rows.first();
}
async function main() {
await mkdir(OUT, { recursive: true });
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
page.on('console', (msg) => {
if (msg.type() === 'error' && !/401|404|Failed to load|DevTools/.test(msg.text())) {
console.log(' [console]', msg.text());
}
});
console.log('login admin...');
await loginAdmin(page);
for (const theme of ['dark', 'light']) {
await setTheme(page, theme);
await page.goto(`${BASE}/admin/records`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1500);
// 找成功行 → 点击 username 链接 / 详情按钮打开 modal
const completedRow = await findRowByStatus(page, '已完成');
if (completedRow) {
// RecordsPage tr 自带 onClick → 整行点击
await completedRow.click({ force: true }).catch(() => {});
await page.waitForTimeout(1200);
const modalOpen = await page.locator('text=任务详情').first().isVisible().catch(() => false);
if (modalOpen) {
await page.screenshot({ path: resolve(OUT, `completed__${theme}.png`) });
console.log(` ✓ completed__${theme}.png`);
// 关闭
await page.locator('button:has-text("✕")').first().click().catch(() => {});
await page.waitForTimeout(400);
} else {
console.log(` ✗ completed__${theme} — modal 没打开`);
}
} else {
console.log(` - 没找到 已完成 行 (${theme})`);
}
// 找失败行
const failedRow = await findRowByStatus(page, '失败');
if (failedRow) {
await failedRow.click({ force: true }).catch(() => {});
await page.waitForTimeout(1200);
const modalOpen = await page.locator('text=任务详情').first().isVisible().catch(() => false);
if (modalOpen) {
await page.screenshot({ path: resolve(OUT, `failed__${theme}.png`) });
console.log(` ✓ failed__${theme}.png`);
await page.locator('button:has-text("✕")').first().click().catch(() => {});
await page.waitForTimeout(400);
} else {
console.log(` ✗ failed__${theme} — modal 没打开`);
}
} else {
console.log(` - 没找到 失败 行 (${theme})`);
}
}
await browser.close();
console.log('\n✅ done — see docs/screenshots/v2/modal/');
}
main().catch(e => { console.error(e); process.exit(1); });

View File

@ -0,0 +1,141 @@
/**
* Theme switching visual regression captures dark + light screenshots of key pages.
*
* Run from web/ directory after starting backend (port 8000) + dev server (port 5173):
* node test/theme-screenshots.mjs
*
* Output: ../docs/screenshots/<page>__<theme>.png
*/
import { chromium } from '@playwright/test';
import { mkdir } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = resolve(__dirname, '../../docs/screenshots/v2');
const BASE = 'http://localhost:5173';
const API = 'http://localhost:8000';
const ADMIN = { username: 'admin', password: 'admin123' };
const TEAM_USER = { username: 'screenshot_user', password: 'shotpass123' };
/** Set theme directly via localStorage + html attribute, no UI click needed. */
async function setTheme(page, theme) {
await page.evaluate((t) => {
localStorage.setItem('airdrama-theme', t);
document.documentElement.dataset.theme = t;
}, theme);
// Reload to ensure ECharts and any once-mounted styles re-init
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(400);
}
/** Programmatic login: POST to API → seed tokens into localStorage → navigate. */
async function login(page, creds) {
const res = await page.request.post(`${API}/api/v1/auth/login`, { data: creds });
if (!res.ok()) throw new Error(`login ${creds.username} failed: ${res.status()} ${await res.text()}`);
const body = await res.json();
const access = body?.tokens?.access;
const refresh = body?.tokens?.refresh;
const user = body?.user;
if (!access) throw new Error(`login ${creds.username}: no access token in response`);
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
await page.evaluate(({ access, refresh, user }) => {
localStorage.setItem('access_token', access);
if (refresh) localStorage.setItem('refresh_token', refresh);
if (user) localStorage.setItem('user', JSON.stringify(user));
}, { access, refresh, user });
}
async function shot(page, slug, theme) {
const file = resolve(OUT_DIR, `${slug}__${theme}.png`);
await page.screenshot({ path: file, fullPage: false });
console.log(`${slug}__${theme}.png`);
}
/** Visit URL, wait for network idle + a settle timeout, then screenshot in both themes. */
async function visitAndCapture(page, slug, url, opts = {}) {
for (const theme of ['dark', 'light']) {
await setTheme(page, theme);
await page.goto(`${BASE}${url}`, { waitUntil: 'domcontentloaded' }).catch(() => {});
await page.waitForTimeout(opts.settle ?? 800);
if (opts.afterLoad) await opts.afterLoad(page);
await shot(page, slug, theme);
}
}
async function main() {
await mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1,
});
const page = await ctx.newPage();
// Mute console errors (API 4xx/5xx in empty DB are noisy but expected)
page.on('pageerror', () => {});
page.on('console', () => {});
console.log(`▼ Capturing to ${OUT_DIR}`);
// 1. Login page (no auth needed) — use a fresh context so localStorage is clean
console.log('\n[1/12] /login');
for (const theme of ['dark', 'light']) {
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
await setTheme(page, theme);
await page.waitForTimeout(500);
await shot(page, '01_login', theme);
}
// 2. Login as admin → admin pages
console.log('\n[2/12] admin login');
await login(page, ADMIN);
await visitAndCapture(page, '02_admin_dashboard', '/admin/dashboard', { settle: 1500 });
console.log('[3/12] /admin/dashboard');
await visitAndCapture(page, '03_admin_users', '/admin/users');
console.log('[4/12] /admin/users');
await visitAndCapture(page, '04_admin_records', '/admin/records');
console.log('[5/12] /admin/records');
await visitAndCapture(page, '05_admin_settings', '/admin/settings');
console.log('[6/12] /admin/settings');
await visitAndCapture(page, '06_admin_security', '/admin/security');
console.log('[7/12] /admin/security');
await visitAndCapture(page, '07_admin_logs', '/admin/logs');
console.log('[8/12] /admin/logs');
await visitAndCapture(page, '08_admin_assets', '/admin/assets');
console.log('[9/12] /admin/assets');
// 3. Switch to team_admin user → generation + profile + team pages
console.log('\n[10/12] team_user login');
await ctx.clearCookies();
await page.evaluate(() => localStorage.clear());
await login(page, TEAM_USER);
await visitAndCapture(page, '09_generation', '/app', { settle: 1200 });
console.log('[10/12] /app');
await visitAndCapture(page, '10_profile', '/profile', { settle: 1200 });
console.log('[11/12] /profile');
await visitAndCapture(page, '11_team_dashboard', '/team/dashboard', { settle: 1500 });
console.log('[12/12] /team/dashboard');
await visitAndCapture(page, '12_team_members', '/team/members');
console.log('[12/12] /team/members');
await browser.close();
console.log('\n✅ done');
}
main().catch((err) => {
console.error('❌ screenshot run failed:', err);
process.exit(1);
});

View File

@ -0,0 +1,141 @@
/**
* Theme switching visual regression captures dark + light screenshots of key pages.
*
* Run from web/ directory after starting backend (port 8000) + dev server (port 5173):
* node test/theme-screenshots.mjs
*
* Output: ../docs/screenshots/<page>__<theme>.png
*/
import { chromium } from '@playwright/test';
import { mkdir } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = resolve(__dirname, '../../docs/screenshots');
const BASE = 'http://localhost:5173';
const API = 'http://localhost:8000';
const ADMIN = { username: 'admin', password: 'admin123' };
const TEAM_USER = { username: 'screenshot_user', password: 'shotpass123' };
/** Set theme directly via localStorage + html attribute, no UI click needed. */
async function setTheme(page, theme) {
await page.evaluate((t) => {
localStorage.setItem('airdrama-theme', t);
document.documentElement.dataset.theme = t;
}, theme);
// Reload to ensure ECharts and any once-mounted styles re-init
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(400);
}
/** Programmatic login: POST to API → seed tokens into localStorage → navigate. */
async function login(page, creds) {
const res = await page.request.post(`${API}/api/v1/auth/login`, { data: creds });
if (!res.ok()) throw new Error(`login ${creds.username} failed: ${res.status()} ${await res.text()}`);
const body = await res.json();
const access = body?.tokens?.access;
const refresh = body?.tokens?.refresh;
const user = body?.user;
if (!access) throw new Error(`login ${creds.username}: no access token in response`);
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
await page.evaluate(({ access, refresh, user }) => {
localStorage.setItem('access_token', access);
if (refresh) localStorage.setItem('refresh_token', refresh);
if (user) localStorage.setItem('user', JSON.stringify(user));
}, { access, refresh, user });
}
async function shot(page, slug, theme) {
const file = resolve(OUT_DIR, `${slug}__${theme}.png`);
await page.screenshot({ path: file, fullPage: false });
console.log(`${slug}__${theme}.png`);
}
/** Visit URL, wait for network idle + a settle timeout, then screenshot in both themes. */
async function visitAndCapture(page, slug, url, opts = {}) {
for (const theme of ['dark', 'light']) {
await setTheme(page, theme);
await page.goto(`${BASE}${url}`, { waitUntil: 'domcontentloaded' }).catch(() => {});
await page.waitForTimeout(opts.settle ?? 800);
if (opts.afterLoad) await opts.afterLoad(page);
await shot(page, slug, theme);
}
}
async function main() {
await mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1,
});
const page = await ctx.newPage();
// Mute console errors (API 4xx/5xx in empty DB are noisy but expected)
page.on('pageerror', () => {});
page.on('console', () => {});
console.log(`▼ Capturing to ${OUT_DIR}`);
// 1. Login page (no auth needed) — use a fresh context so localStorage is clean
console.log('\n[1/12] /login');
for (const theme of ['dark', 'light']) {
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
await setTheme(page, theme);
await page.waitForTimeout(500);
await shot(page, '01_login', theme);
}
// 2. Login as admin → admin pages
console.log('\n[2/12] admin login');
await login(page, ADMIN);
await visitAndCapture(page, '02_admin_dashboard', '/admin/dashboard', { settle: 1500 });
console.log('[3/12] /admin/dashboard');
await visitAndCapture(page, '03_admin_users', '/admin/users');
console.log('[4/12] /admin/users');
await visitAndCapture(page, '04_admin_records', '/admin/records');
console.log('[5/12] /admin/records');
await visitAndCapture(page, '05_admin_settings', '/admin/settings');
console.log('[6/12] /admin/settings');
await visitAndCapture(page, '06_admin_security', '/admin/security');
console.log('[7/12] /admin/security');
await visitAndCapture(page, '07_admin_logs', '/admin/logs');
console.log('[8/12] /admin/logs');
await visitAndCapture(page, '08_admin_assets', '/admin/assets');
console.log('[9/12] /admin/assets');
// 3. Switch to team_admin user → generation + profile + team pages
console.log('\n[10/12] team_user login');
await ctx.clearCookies();
await page.evaluate(() => localStorage.clear());
await login(page, TEAM_USER);
await visitAndCapture(page, '09_generation', '/app', { settle: 1200 });
console.log('[10/12] /app');
await visitAndCapture(page, '10_profile', '/profile', { settle: 1200 });
console.log('[11/12] /profile');
await visitAndCapture(page, '11_team_dashboard', '/team/dashboard', { settle: 1500 });
console.log('[12/12] /team/dashboard');
await visitAndCapture(page, '12_team_members', '/team/members');
console.log('[12/12] /team/members');
await browser.close();
console.log('\n✅ done');
}
main().catch((err) => {
console.error('❌ screenshot run failed:', err);
process.exit(1);
});

287
web/test/v2-smoke.mjs Normal file
View File

@ -0,0 +1,287 @@
/**
* V2 Smoke Test 验证主题切换/登录/导航/Modal 等关键交互在 V2 改动下没坏
*
* Run: node test/v2-smoke.mjs
* Pre-req: backend 8000 + frontend 5173 都已启动
*/
import { chromium } from '@playwright/test';
const BASE = 'http://localhost:5173';
const API = 'http://localhost:8000';
const ADMIN = { username: 'admin', password: 'admin123' };
const TEAM_USER = { username: 'screenshot_user', password: 'shotpass123' };
const results = [];
const errors = [];
function pass(name) {
results.push({ name, status: '✓ PASS' });
console.log(`${name}`);
}
function fail(name, err) {
results.push({ name, status: '✗ FAIL' });
errors.push({ name, err: err?.message || String(err) });
console.log(`${name}: ${err?.message || err}`);
}
async function login(page, creds) {
const res = await page.request.post(`${API}/api/v1/auth/login`, { data: creds });
if (!res.ok()) throw new Error(`login ${creds.username}: ${res.status()}`);
const body = await res.json();
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
await page.evaluate(({ access, refresh, user }) => {
localStorage.setItem('access_token', access);
if (refresh) localStorage.setItem('refresh_token', refresh);
if (user) localStorage.setItem('user', JSON.stringify(user));
}, { access: body?.tokens?.access, refresh: body?.tokens?.refresh, user: body?.user });
}
async function main() {
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const consoleErrors = [];
const pageErrors = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
const t = msg.text();
// 忽略已知 API 401/404 (test_db 数据缺失) + DevTools React 警告
if (/401|404|Failed to load resource|Download the React DevTools/.test(t)) return;
consoleErrors.push(t);
}
});
page.on('pageerror', (e) => pageErrors.push(e.message));
console.log('\n══════════════════════════════════════════');
console.log(' V2 SMOKE TEST — 主题切换 / 登录 / 导航');
console.log('══════════════════════════════════════════');
// ─────────────────────────────────────
// Group 1: Login page (no auth) + LoginModal submitBtn
// ─────────────────────────────────────
console.log('\n[1/5] Login Page + LoginModal');
try {
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(800);
// LoginModal 应自动弹出 (autoLogin)
const hasLoginModal = await page.locator('input[type="text"]').first().isVisible();
if (hasLoginModal) pass('1.1 LoginModal 自动弹出 (autoLogin)');
else fail('1.1 LoginModal 自动弹出', new Error('找不到用户名输入框'));
// 数据-theme 属性默认应 dark
const theme1 = await page.evaluate(() => document.documentElement.dataset.theme);
if (theme1 === 'dark') pass('1.2 默认主题=dark');
else fail('1.2 默认主题=dark', new Error(`got ${theme1}`));
// V2: 浅色切换后 LoginModal 仍可见 (LandingPage data-theme="dark" 被移除)
await page.evaluate(() => {
localStorage.setItem('airdrama-theme', 'light');
document.documentElement.dataset.theme = 'light';
});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(800);
const lightLoginVisible = await page.locator('input[type="text"]').first().isVisible();
if (lightLoginVisible) pass('1.3 浅色 LoginModal 仍渲染 (V2 关键)');
else fail('1.3 浅色 LoginModal 仍渲染', new Error('浅色下 LoginModal 不可见'));
// submitBtn 仍可点 (V2 字重 600 + 加 box-shadow,不改 onClick)
const submitBtn = page.locator('button:has-text("登录")').first();
const submitVisible = await submitBtn.isVisible();
if (submitVisible) pass('1.4 submitBtn 在浅色下仍可见可点');
else fail('1.4 submitBtn', new Error('登录按钮不可见'));
} catch (e) {
fail('1.x LoginPage block', e);
}
// ─────────────────────────────────────
// Group 2: 实际登录 — admin
// ─────────────────────────────────────
console.log('\n[2/5] 登录 admin 走完真实流程');
try {
await page.evaluate(() => { localStorage.clear(); });
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
await page.locator('input[type="text"]').first().fill(ADMIN.username);
await page.locator('input[type="password"]').first().fill(ADMIN.password);
await page.locator('button:has-text("登录")').first().click();
await page.waitForURL((url) => url.pathname.startsWith('/admin'), { timeout: 8000 });
pass('2.1 admin 登录成功 → 跳转 /admin/*');
// access_token 已持久化
const hasToken = await page.evaluate(() => !!localStorage.getItem('access_token'));
if (hasToken) pass('2.2 access_token 写入 localStorage');
else fail('2.2 access_token', new Error('localStorage 没有 access_token'));
} catch (e) {
fail('2.x admin 登录', e);
}
// ─────────────────────────────────────
// Group 3: Theme toggle 端到端
// ─────────────────────────────────────
console.log('\n[3/5] Theme Toggle 端到端');
try {
// 确保深色态进入
await page.evaluate(() => {
localStorage.setItem('airdrama-theme', 'dark');
document.documentElement.dataset.theme = 'dark';
});
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(800);
const themeBefore = await page.evaluate(() => document.documentElement.dataset.theme);
if (themeBefore === 'dark') pass('3.1 初始 theme=dark');
else fail('3.1 初始 theme', new Error(`got ${themeBefore}`));
// 找 Sidebar 切换按钮 (aria-label 含 "切换到浅色主题")
const toggle = page.locator('button[aria-label*="切换"]').first();
const toggleVisible = await toggle.isVisible();
if (toggleVisible) pass('3.2 Sidebar 主题切换按钮可见');
else fail('3.2 切换按钮', new Error('找不到切换按钮'));
if (toggleVisible) {
await toggle.click();
await page.waitForTimeout(500);
const themeAfter = await page.evaluate(() => document.documentElement.dataset.theme);
if (themeAfter === 'light') pass('3.3 点按钮 → theme=light');
else fail('3.3 切换到 light', new Error(`got ${themeAfter}`));
// localStorage 持久化
const stored = await page.evaluate(() => localStorage.getItem('airdrama-theme'));
if (stored === 'light') pass('3.4 localStorage 持久化 light');
else fail('3.4 localStorage', new Error(`got ${stored}`));
// 刷新后保持
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
const themePersisted = await page.evaluate(() => document.documentElement.dataset.theme);
if (themePersisted === 'light') pass('3.5 刷新后 theme=light 保持');
else fail('3.5 刷新持久化', new Error(`got ${themePersisted}`));
// 切回深色
await toggle.click();
await page.waitForTimeout(300);
const themeBack = await page.evaluate(() => document.documentElement.dataset.theme);
if (themeBack === 'dark') pass('3.6 再点 → theme=dark (来回切换 OK)');
else fail('3.6 切回 dark', new Error(`got ${themeBack}`));
}
} catch (e) {
fail('3.x theme toggle', e);
}
// ─────────────────────────────────────
// Group 4: Admin sidebar nav (V2 AdminLayout transparent + glass 改动)
// ─────────────────────────────────────
console.log('\n[4/5] Admin 侧栏导航');
const adminRoutes = [
{ name: '用户管理', url: '/admin/users' },
{ name: '消费记录', url: '/admin/records' },
{ name: '内容资产', url: '/admin/assets' },
{ name: '系统设置', url: '/admin/settings' },
{ name: '安全日志', url: '/admin/security' },
{ name: '操作日志', url: '/admin/logs' },
{ name: '团队管理', url: '/admin/teams' },
];
try {
for (const r of adminRoutes) {
try {
await page.goto(`${BASE}${r.url}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
// 页面 mount 标志:body 不为空 + 找到 sidebar 主导航
const sidebarVisible = await page.locator('text=AirDrama Admin').first().isVisible().catch(() => false);
if (sidebarVisible) pass(`4.x ${r.name} (${r.url}) 渲染 OK + sidebar 在`);
else fail(`4.x ${r.name}`, new Error('sidebar 或页面没渲染'));
} catch (e) {
fail(`4.x ${r.name}`, e);
}
}
} catch (e) {
fail('4.x admin 导航', e);
}
// ─────────────────────────────────────
// Group 5: Team user → 生成页 + Modal 交互
// ─────────────────────────────────────
console.log('\n[5/5] 团管用户 → 生成页 + AnnouncementModal');
try {
await ctx.clearCookies();
await page.evaluate(() => localStorage.clear());
await login(page, TEAM_USER);
await page.goto(`${BASE}/app`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1200);
// 生成页 sidebar 可见 (component/Sidebar 76px 窄版,V2 玻璃)
const genSidebar = page.locator('aside').first();
const sidebarOk = await genSidebar.isVisible();
if (sidebarOk) pass('5.1 生成页 Sidebar (76px 玻璃版) 可见');
else fail('5.1 生成页 Sidebar', new Error('找不到 aside 元素'));
// 主题切换按钮在
const themeBtn = page.locator('button[aria-label*="切换"]').first();
const themeBtnOk = await themeBtn.isVisible();
if (themeBtnOk) pass('5.2 团管 Sidebar 主题切换按钮在');
else fail('5.2 主题切换按钮', new Error('团管侧栏没切换按钮'));
// 公告弹窗 — first-visit only,所以用 soft check (有就关掉,没有跳过)
const announcement = page.locator('text=AirDrama 使用说明').first();
const annOk = await announcement.isVisible({ timeout: 1500 }).catch(() => false);
if (annOk) {
pass('5.3 AnnouncementModal 弹出 (首次访问)');
const closeBtn = page.locator('button:has-text("我知道了")').first();
if (await closeBtn.isVisible().catch(() => false)) {
await closeBtn.click();
await page.waitForTimeout(400);
const annStillOpen = await announcement.isVisible().catch(() => false);
if (!annStillOpen) pass('5.4 公告 modal 关闭按钮 OK');
else fail('5.4 公告关闭', new Error('点了我知道了 modal 还在'));
}
} else {
pass('5.3 公告 modal soft-skip (已读过)');
}
// 5.5 生成页 InputBar 核心元素 — prompt 输入区可见
const promptInput = page.locator('[contenteditable="true"]').first();
const inputOk = await promptInput.isVisible({ timeout: 3000 }).catch(() => false);
if (inputOk) pass('5.5 InputBar prompt 输入区可见 (生成页核心)');
else fail('5.5 InputBar', new Error('找不到 contenteditable prompt 输入'));
} catch (e) {
fail('5.x team user 生成页', e);
}
// ─────────────────────────────────────
// 收尾 — console / pageerror
// ─────────────────────────────────────
if (consoleErrors.length > 0) {
fail('JS 运行错误', new Error(`${consoleErrors.length} 个 console.error:\n ` + consoleErrors.slice(0, 5).join('\n ')));
} else {
pass('全程无 console.error');
}
if (pageErrors.length > 0) {
fail('JS 异常崩溃', new Error(`${pageErrors.length} 个未捕获:\n ` + pageErrors.slice(0, 5).join('\n ')));
} else {
pass('全程无 page crash');
}
await browser.close();
// 汇总
console.log('\n══════════════════════════════════════════');
console.log(' 汇总');
console.log('══════════════════════════════════════════');
const passCount = results.filter(r => r.status.includes('PASS')).length;
const failCount = results.filter(r => r.status.includes('FAIL')).length;
console.log(`Pass: ${passCount} Fail: ${failCount}`);
if (failCount > 0) {
console.log('\n❌ 失败明细:');
errors.forEach(e => console.log(`${e.name}: ${e.err}`));
process.exit(1);
}
console.log('\n✅ 全部 smoke check 通过');
}
main().catch(err => {
console.error('❌ Smoke test crashed:', err);
process.exit(1);
});