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
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 7m28s
This commit is contained in:
commit
d7b0016d74
@ -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
1360
docs/todo/showcase.html
Normal file
File diff suppressed because it is too large
Load Diff
250
docs/todo/亮色主题切换-完成报告.md
Normal file
250
docs/todo/亮色主题切换-完成报告.md
Normal 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 启动时改写 |
|
||||||
|
|
||||||
|
### 修改 — 替换硬编码颜色为 var(49 个文件)
|
||||||
|
|
||||||
|
#### TSX 文件(13 个)
|
||||||
|
|
||||||
|
| 文件 | 替换数 |
|
||||||
|
|---|---|
|
||||||
|
| `pages/DashboardPage.tsx` | 31(ECharts 配置) |
|
||||||
|
| `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 接入 themeStore(4 个)
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `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.05;border 整体加深 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))` 没用 var(CSS 限制: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.8(AAA),符合 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 files:DashboardPage / 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。
|
||||||
313
docs/todo/亮色主题切换.md
Normal file
313
docs/todo/亮色主题切换.md
Normal 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.1(AAA 级别)✓
|
||||||
|
- 浅色背景 + 白字:对比度 ~4.4(接近 AA 边界,但按钮上的小字可能不够)
|
||||||
|
- 解决:浅色模式 `--color-primary: #5048cc`(加深 18%),按钮上白字对比度 ~6.8(AAA)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证清单
|
||||||
|
|
||||||
|
切换前后两种主题下都要看:
|
||||||
|
|
||||||
|
- [ ] 登录页(含 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
|
||||||
262
docs/todo/亮色主题切换V2-完成报告.md
Normal file
262
docs/todo/亮色主题切换V2-完成报告.md
Normal 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/)
|
||||||
516
docs/todo/亮色主题切换V2.md
Normal file
516
docs/todo/亮色主题切换V2.md
Normal file
@ -0,0 +1,516 @@
|
|||||||
|
# 亮色主题切换 V2 — 玻璃质感重做方案
|
||||||
|
|
||||||
|
**起因**:V1(commit `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)
|
||||||
|
|
|
||||||
|
├── 内嵌实体白卡 × 4(mini 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-bg;keep 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)` | 已经是玻璃 token,V2 调 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-bg(CSS 限制无法 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 Geist:https://vercel.com/design/geist(design-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 同语言,不再是"色块版深色取反"
|
||||||
@ -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" />
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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%)`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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); }
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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' ? '音频' : '图片'}
|
||||||
|
|||||||
@ -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,58 +40,67 @@ 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}>
|
||||||
{/* Status */}
|
{/* ── 左侧:视频 + 参考素材 ── */}
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={mediaPanel}>
|
||||||
<span style={{ ...statusBadge, color: st.color, background: st.bg }}>{st.label}</span>
|
<MediaArea record={r} />
|
||||||
</div>
|
{refs.length > 0 && (
|
||||||
|
<>
|
||||||
{/* Error */}
|
<div style={sectionTitle}>参考素材({refs.length})</div>
|
||||||
{r.status === 'failed' && r.error_message && (
|
<div style={refScrollBox}>
|
||||||
<div style={errorBox}>
|
<ReferenceList references={refs} />
|
||||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>失败原因</div>
|
|
||||||
<div>{r.error_message}</div>
|
|
||||||
{r.raw_error && r.raw_error !== r.error_message && (
|
|
||||||
<div style={{ marginTop: 8, fontSize: 11, color: '#888', fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
|
||||||
原始错误:{r.raw_error}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Info Grid */}
|
|
||||||
<div style={sectionTitle}>基本信息</div>
|
|
||||||
<div style={infoGrid}>
|
|
||||||
{r.ark_task_id && <InfoItem label="任务ID" value={r.ark_task_id} />}
|
|
||||||
{r.username && <InfoItem label="用户" value={r.username} />}
|
|
||||||
{showTeam && r.team_name && <InfoItem label="团队" value={r.team_name} />}
|
|
||||||
<InfoItem label="提交时间" value={new Date(r.created_at).toLocaleString('zh-CN')} />
|
|
||||||
<InfoItem label="耗时" value={elapsed} />
|
|
||||||
<InfoItem label="模型" value={r.model === 'seedance_2.0_fast' ? 'AirDrama Fast' : 'AirDrama'} />
|
|
||||||
<InfoItem label="模式" value={MODE_MAP[r.mode] || r.mode} />
|
|
||||||
<InfoItem label="比例" value={r.aspect_ratio || '-'} />
|
|
||||||
<InfoItem label="分辨率" value={r.resolution ? r.resolution.toUpperCase() : '-'} />
|
|
||||||
<InfoItem label="时长" value={r.duration != null ? `${r.duration}秒` : '-'} />
|
|
||||||
<InfoItem label="Tokens" value={(r.tokens_consumed || 0).toLocaleString()} />
|
|
||||||
{showCost && <InfoItem label="费用" value={`¥${(r.cost_amount || 0).toFixed(2)}`} />}
|
|
||||||
{r.seed != null && r.seed !== -1 && <InfoItem label="种子值" value={String(r.seed)} />}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Prompt */}
|
{/* ── 右侧:信息 + 提示词 ── */}
|
||||||
<div style={sectionTitle}>提示词</div>
|
<div style={infoPanel}>
|
||||||
<div style={promptBox}>{r.prompt || '(无提示词)'}</div>
|
{/* Status */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<span style={{ ...statusBadge, color: st.color, background: st.bg }}>{st.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* References */}
|
{/* Error */}
|
||||||
{refs.length > 0 && (
|
{r.status === 'failed' && r.error_message && (
|
||||||
<>
|
<div style={errorBox}>
|
||||||
<div style={sectionTitle}>参考素材({refs.length})</div>
|
<div style={{ fontWeight: 500, marginBottom: 4 }}>失败原因</div>
|
||||||
<ReferenceList references={refs} />
|
<div>{r.error_message}</div>
|
||||||
</>
|
{r.raw_error && r.raw_error !== r.error_message && (
|
||||||
)}
|
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--color-text-tertiary)', fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
||||||
|
原始错误:{r.raw_error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info Grid */}
|
||||||
|
<div style={{ ...sectionTitle, marginTop: 0 }}>基本信息</div>
|
||||||
|
<div style={infoGrid}>
|
||||||
|
{r.ark_task_id && <InfoItem label="任务ID" value={r.ark_task_id} />}
|
||||||
|
{r.username && <InfoItem label="用户" value={r.username} />}
|
||||||
|
{showTeam && r.team_name && <InfoItem label="团队" value={r.team_name} />}
|
||||||
|
<InfoItem label="提交时间" value={new Date(r.created_at).toLocaleString('zh-CN')} />
|
||||||
|
<InfoItem label="耗时" value={elapsed} />
|
||||||
|
<InfoItem label="模型" value={r.model === 'seedance_2.0_fast' ? 'AirDrama Fast' : 'AirDrama'} />
|
||||||
|
<InfoItem label="模式" value={MODE_MAP[r.mode] || r.mode} />
|
||||||
|
<InfoItem label="比例" value={r.aspect_ratio || '-'} />
|
||||||
|
<InfoItem label="分辨率" value={r.resolution ? r.resolution.toUpperCase() : '-'} />
|
||||||
|
<InfoItem label="时长" value={r.duration != null ? `${r.duration}秒` : '-'} />
|
||||||
|
<InfoItem label="Tokens" value={(r.tokens_consumed || 0).toLocaleString()} />
|
||||||
|
{showCost && <InfoItem label="费用" value={`¥${(r.cost_amount || 0).toFixed(2)}`} />}
|
||||||
|
{r.seed != null && r.seed !== -1 && <InfoItem label="种子值" value={String(r.seed)} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prompt */}
|
||||||
|
<div style={sectionTitle}>提示词</div>
|
||||||
|
<div style={promptBox}>{r.prompt || '(无提示词)'}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -98,51 +108,259 @@ export function RecordDetailModal({ record: r, onClose, showTeam, showCost }: Pr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoItem({ label, value }: { label: string; value: string }) {
|
/**
|
||||||
|
* 左侧媒体区 — 根据任务状态决定显示什么:
|
||||||
|
* - completed + result_url → 视频播放器(controls,不自动播放)
|
||||||
|
* - completed - result_url → "视频已生成"占位
|
||||||
|
* - failed → RGB 故障字 "生成失败" + 错误原因摘要 + 斜纹底纹
|
||||||
|
* - processing / queued → 旋转 spinner + 文字
|
||||||
|
*/
|
||||||
|
function MediaArea({ record: r }: { record: AdminRecord }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={mediaFrame}>
|
||||||
<div style={{ fontSize: 11, color: '#888', marginBottom: 2 }}>{label}</div>
|
{r.status === 'completed' && r.result_url ? (
|
||||||
<div style={{ fontSize: 13, color: '#e2e2ea', wordBreak: 'break-all' }}>{value}</div>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--color-text-tertiary)', marginBottom: 2 }}>{label}</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--color-text-light)', wordBreak: 'break-all' }}>{value}</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',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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)',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
16
web/src/lib/themeColor.ts
Normal 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();
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)',
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 */
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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); }
|
||||||
|
|||||||
@ -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)',
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
53
web/src/store/theme.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@ -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 {
|
||||||
|
|||||||
158
web/test/modal-interaction.mjs
Normal file
158
web/test/modal-interaction.mjs
Normal 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
105
web/test/modal-preview.mjs
Normal 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); });
|
||||||
141
web/test/theme-screenshots-v2.mjs
Normal file
141
web/test/theme-screenshots-v2.mjs
Normal 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);
|
||||||
|
});
|
||||||
141
web/test/theme-screenshots.mjs
Normal file
141
web/test/theme-screenshots.mjs
Normal 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
287
web/test/v2-smoke.mjs
Normal 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);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user