e.stopPropagation()}>
修改密码
@@ -131,7 +153,7 @@ export function AdminLayout() {
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' }}>取消
+ 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 ? '修改中...' : '确认修改'}
diff --git a/web/src/pages/AnomalyLogPage.tsx b/web/src/pages/AnomalyLogPage.tsx
index fedda02..1a83c72 100644
--- a/web/src/pages/AnomalyLogPage.tsx
+++ b/web/src/pages/AnomalyLogPage.tsx
@@ -144,8 +144,8 @@ export function AnomalyLogPage() {
{a.level === 'critical' ? '严重' : '警告'}
@@ -164,7 +164,7 @@ export function AnomalyLogPage() {
{a.auto_disabled ? (
-
+
已封禁{a.disabled_target === 'team' ? '团队' : '用户'}
) : a.alerted ? (
diff --git a/web/src/pages/AssetsPage.module.css b/web/src/pages/AssetsPage.module.css
index dc56b19..3515400 100644
--- a/web/src/pages/AssetsPage.module.css
+++ b/web/src/pages/AssetsPage.module.css
@@ -15,7 +15,7 @@
/* Tab header */
.tabHeader {
padding: 20px 32px 0;
- border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+ border-bottom: 1px solid var(--color-border-soft);
}
.tabs {
@@ -125,7 +125,7 @@
aspect-ratio: 16 / 9;
border-radius: 10px;
overflow: hidden;
- background: rgba(0, 0, 0, 0.3);
+ background: var(--color-bg-thumbnail);
cursor: pointer;
transition: transform 0.15s;
}
@@ -144,7 +144,7 @@
.thumbPlaceholder {
width: 100%;
height: 100%;
- background: #1a1a24;
+ background: var(--color-bg-dropdown-elevated);
}
.durationBadge {
@@ -153,8 +153,8 @@
left: 6px;
padding: 2px 6px;
border-radius: 4px;
- background: rgba(0, 0, 0, 0.6);
- color: #fff;
+ background: var(--color-modal-overlay);
+ color: var(--color-on-overlay);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
@@ -162,6 +162,6 @@
.thumbOverlay {
position: absolute;
inset: 0;
- background: rgba(0, 0, 0, 0.15);
+ background: var(--color-bg-thumbnail-hover);
pointer-events: none;
}
diff --git a/web/src/pages/AssetsPage.tsx b/web/src/pages/AssetsPage.tsx
index 7638863..b8d8451 100644
--- a/web/src/pages/AssetsPage.tsx
+++ b/web/src/pages/AssetsPage.tsx
@@ -186,8 +186,8 @@ export function AssetsPage() {
onClick={loadMore}
disabled={isLoadingMore}
style={{
- background: 'rgba(255,255,255,0.06)',
- border: '1px solid rgba(255,255,255,0.1)',
+ background: 'var(--color-bg-card)',
+ border: '1px solid var(--color-border-card)',
borderRadius: 8,
padding: '8px 32px',
color: 'var(--color-text-secondary)',
diff --git a/web/src/pages/AuditLogsPage.module.css b/web/src/pages/AuditLogsPage.module.css
index 1f915bb..f1932cb 100644
--- a/web/src/pages/AuditLogsPage.module.css
+++ b/web/src/pages/AuditLogsPage.module.css
@@ -8,7 +8,7 @@
}
.searchInput:focus { border-color: var(--color-primary); }
.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; }
.refreshBtn {
padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s;
@@ -19,23 +19,24 @@
.tableWrapper {
background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); overflow-x: auto;
+ box-shadow: var(--shadow-card-light);
}
.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 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: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); }
-.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; }
.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; }
.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; }
-.changeArrow { color: #8b8ea8; }
+.changeArrow { color: var(--color-text-secondary); }
.changeNew { color: var(--color-success); }
.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: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; }
diff --git a/web/src/pages/AuditLogsPage.tsx b/web/src/pages/AuditLogsPage.tsx
index c278183..84fef1a 100644
--- a/web/src/pages/AuditLogsPage.tsx
+++ b/web/src/pages/AuditLogsPage.tsx
@@ -89,7 +89,7 @@ function renderChanges(before: Record | null, after: Record 0
? {items}
- : 无变更 ;
+ : 无变更 ;
}
export function AuditLogsPage() {
@@ -188,7 +188,7 @@ export function AuditLogsPage() {
{log.action_display}
{log.target_name || '-'}
- {log.target_type && ({log.target_type}) }
+ {log.target_type && ({log.target_type}) }
{renderChanges(log.before, log.after)}
{log.ip_address || '-'}
diff --git a/web/src/pages/AuthPage.module.css b/web/src/pages/AuthPage.module.css
index 3a87dc3..3e18938 100644
--- a/web/src/pages/AuthPage.module.css
+++ b/web/src/pages/AuthPage.module.css
@@ -54,7 +54,7 @@
.input {
height: 44px;
padding: 0 14px;
- background: rgba(255, 255, 255, 0.04);
+ background: var(--color-bg-upload);
border: 1px solid var(--color-border-input-bar);
border-radius: 10px;
color: var(--color-text-primary);
@@ -72,18 +72,18 @@
}
.error {
- color: #ff4d4f;
+ color: var(--color-danger-text);
font-size: 13px;
text-align: center;
padding: 8px;
- background: rgba(255, 77, 79, 0.08);
+ background: var(--color-danger-bg-soft);
border-radius: 8px;
}
.submitBtn {
height: 44px;
background: var(--color-primary);
- color: #fff;
+ color: var(--color-on-primary);
border: none;
border-radius: 10px;
font-size: 15px;
diff --git a/web/src/pages/DashboardPage.module.css b/web/src/pages/DashboardPage.module.css
index 7015709..2c86ba5 100644
--- a/web/src/pages/DashboardPage.module.css
+++ b/web/src/pages/DashboardPage.module.css
@@ -21,6 +21,7 @@
border: 1px solid var(--color-border-card);
border-radius: var(--radius-card);
padding: 20px;
+ box-shadow: var(--shadow-card-light);
}
.statLabel {
@@ -48,12 +49,12 @@
.positive {
color: var(--color-success);
- background: rgba(0, 184, 148, 0.1);
+ background: var(--color-success-bg-hover);
}
.negative {
color: var(--color-danger);
- background: rgba(231, 76, 60, 0.1);
+ background: var(--color-danger-bg-hover);
}
.chartSection {
@@ -72,6 +73,7 @@
border: 1px solid var(--color-border-card);
border-radius: var(--radius-card);
padding: 16px;
+ box-shadow: var(--shadow-card-light);
}
/* Skeleton loading */
diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx
index 036a4b8..07e5b5c 100644
--- a/web/src/pages/DashboardPage.tsx
+++ b/web/src/pages/DashboardPage.tsx
@@ -7,6 +7,8 @@ import { CanvasRenderer } from 'echarts/renderers';
import { adminApi } from '../lib/api';
import type { AdminStats } from '../types';
import { showToast } from '../components/Toast';
+import { c } from '../lib/themeColor';
+import { useThemeStore } from '../store/theme';
import styles from './DashboardPage.module.css';
echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, CanvasRenderer]);
@@ -14,6 +16,9 @@ echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendCompone
export function DashboardPage() {
const [stats, setStats] = useState(null);
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 () => {
try {
@@ -54,9 +59,9 @@ export function DashboardPage() {
const trendOption: echarts.EChartsCoreOption = {
tooltip: {
trigger: 'axis',
- backgroundColor: 'rgba(13, 13, 26, 0.95)',
- borderColor: 'rgba(255, 255, 255, 0.10)',
- textStyle: { color: '#f1f0ff', fontSize: 12 },
+ backgroundColor: c('tooltip-bg'),
+ borderColor: c('tooltip-border'),
+ textStyle: { color: c('text-primary'), fontSize: 12 },
formatter: (params: unknown) => {
const p = (params as { name: string; value: number }[])[0];
return `${p.name} 消费: ¥${p.value.toFixed(2)}`;
@@ -66,27 +71,27 @@ export function DashboardPage() {
xAxis: {
type: 'category',
data: stats.daily_trend.map((d) => d.date.slice(5)),
- axisLabel: { color: '#8b8ea8', fontSize: 11 },
- axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } },
+ axisLabel: { color: c('text-secondary'), fontSize: 11 },
+ axisLine: { lineStyle: { color: c('chart-axis') } },
},
yAxis: {
type: 'value',
- axisLabel: { color: '#8b8ea8', fontSize: 11 },
- splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } },
+ axisLabel: { color: c('text-secondary'), fontSize: 11 },
+ splitLine: { lineStyle: { color: c('chart-grid') } },
},
dataZoom: [{ type: 'inside', start: 0, end: 100 }],
series: [{
type: 'line',
data: stats.daily_trend.map((d) => d.cost),
smooth: true,
- lineStyle: { color: '#6c63ff', width: 2 },
+ lineStyle: { color: c('primary'), width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
- { offset: 0, color: 'rgba(108, 99, 255, 0.25)' },
- { offset: 1, color: 'rgba(108, 99, 255, 0.02)' },
+ { offset: 0, color: c('chart-area-from') },
+ { offset: 1, color: c('chart-area-to') },
]),
},
- itemStyle: { color: '#6c63ff' },
+ itemStyle: { color: c('primary') },
}],
};
@@ -95,21 +100,21 @@ export function DashboardPage() {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
- backgroundColor: 'rgba(13, 13, 26, 0.95)',
- borderColor: 'rgba(255, 255, 255, 0.10)',
- textStyle: { color: '#f1f0ff', fontSize: 12 },
+ backgroundColor: c('tooltip-bg'),
+ borderColor: c('tooltip-border'),
+ textStyle: { color: c('text-primary'), fontSize: 12 },
},
grid: { left: 80, right: 40, top: 10, bottom: 20 },
xAxis: {
type: 'value',
- axisLabel: { color: '#8b8ea8', fontSize: 11 },
- splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } },
+ axisLabel: { color: c('text-secondary'), fontSize: 11 },
+ splitLine: { lineStyle: { color: c('chart-grid') } },
},
yAxis: {
type: 'category',
data: sortedTeams.map((t) => t.name),
- axisLabel: { color: '#8b8ea8', fontSize: 12 },
- axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } },
+ axisLabel: { color: c('text-secondary'), fontSize: 12 },
+ axisLine: { lineStyle: { color: c('chart-axis') } },
},
series: [{
type: 'bar',
@@ -117,15 +122,15 @@ export function DashboardPage() {
barWidth: 16,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
- { offset: 0, color: '#00b8e6' },
- { offset: 1, color: '#06d6a0' },
+ { offset: 0, color: c('info') },
+ { offset: 1, color: c('accent-2') },
]),
borderRadius: [0, 4, 4, 0],
},
label: {
show: true,
position: 'right',
- color: '#8b8ea8',
+ color: c('text-secondary'),
fontSize: 11,
formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`,
},
@@ -137,21 +142,21 @@ export function DashboardPage() {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
- backgroundColor: 'rgba(13, 13, 26, 0.95)',
- borderColor: 'rgba(255, 255, 255, 0.10)',
- textStyle: { color: '#f1f0ff', fontSize: 12 },
+ backgroundColor: c('tooltip-bg'),
+ borderColor: c('tooltip-border'),
+ textStyle: { color: c('text-primary'), fontSize: 12 },
},
grid: { left: 80, right: 40, top: 10, bottom: 20 },
xAxis: {
type: 'value',
- axisLabel: { color: '#8b8ea8', fontSize: 11 },
- splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } },
+ axisLabel: { color: c('text-secondary'), fontSize: 11 },
+ splitLine: { lineStyle: { color: c('chart-grid') } },
},
yAxis: {
type: 'category',
data: sortedUsers.map((u) => u.username),
- axisLabel: { color: '#8b8ea8', fontSize: 12 },
- axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } },
+ axisLabel: { color: c('text-secondary'), fontSize: 12 },
+ axisLine: { lineStyle: { color: c('chart-axis') } },
},
series: [{
type: 'bar',
@@ -159,15 +164,15 @@ export function DashboardPage() {
barWidth: 16,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
- { offset: 0, color: '#6c63ff' },
- { offset: 1, color: '#8b5cf6' },
+ { offset: 0, color: c('primary') },
+ { offset: 1, color: c('primary-2') },
]),
borderRadius: [0, 4, 4, 0],
},
label: {
show: true,
position: 'right',
- color: '#8b8ea8',
+ color: c('text-secondary'),
fontSize: 11,
formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`,
},
@@ -217,7 +222,7 @@ export function DashboardPage() {
@@ -227,7 +232,7 @@ export function DashboardPage() {
)}
@@ -235,7 +240,7 @@ export function DashboardPage() {
diff --git a/web/src/pages/LandingPage.module.css b/web/src/pages/LandingPage.module.css
index a84d830..af46151 100644
--- a/web/src/pages/LandingPage.module.css
+++ b/web/src/pages/LandingPage.module.css
@@ -6,7 +6,7 @@
align-items: center;
justify-content: center;
overflow: hidden;
- background: #000;
+ background: var(--color-bg-page);
z-index: 2;
}
@@ -36,7 +36,7 @@
width: 80px;
height: 80px;
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;
}
@@ -44,7 +44,7 @@
font-family: 'Space Grotesk', sans-serif;
font-size: 48px;
font-weight: 300;
- color: #f1f0ff;
+ color: var(--color-text-primary);
letter-spacing: 0.1em;
margin-bottom: 12px;
line-height: 1.1;
@@ -55,7 +55,7 @@
font-family: 'Space Grotesk', sans-serif;
font-size: 14px;
font-weight: 300;
- color: rgba(255, 255, 255, 0.5);
+ color: var(--color-text-on-glass-soft);
letter-spacing: 0.15em;
margin-bottom: 48px;
animation: fadeUp 1.2s ease-out 0.3s both;
@@ -85,45 +85,47 @@
border-radius: 10px;
cursor: pointer;
transition: all 0.3s;
- backdrop-filter: blur(12px);
- -webkit-backdrop-filter: blur(12px);
+ backdrop-filter: var(--bf-glass-sm);
+ -webkit-backdrop-filter: var(--bf-glass-sm);
}
.btnPrimary {
- background: rgba(120, 220, 200, 0.12);
- border: 1px solid rgba(120, 220, 200, 0.3);
+ background: var(--color-mint-accent-bg);
+ border: 1px solid var(--color-mint-accent-border);
}
.btnPrimary .btnName {
font-family: 'Space Grotesk', sans-serif;
font-size: 15px;
font-weight: 500;
- color: #7edcc8;
+ color: var(--color-mint-accent);
}
.btnPrimary:hover {
- background: rgba(120, 220, 200, 0.22);
- box-shadow: 0 0 30px rgba(120, 220, 200, 0.15);
+ background: var(--color-mint-accent-bg-hover);
+ box-shadow: 0 0 30px var(--color-mint-accent-glow);
}
.btnGhost {
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid rgba(255, 255, 255, 0.1);
+ background: var(--color-bg-glass);
+ border: 1px solid var(--color-border-card);
+ /* V2: 玻璃顶边白高光 */
+ box-shadow: inset 0 1px 0 var(--color-inset-highlight);
}
.btnGhost .btnName {
font-family: 'Space Grotesk', sans-serif;
font-size: 15px;
font-weight: 500;
- color: rgba(255, 255, 255, 0.7);
+ color: var(--color-text-on-glass);
}
.btnGhost:hover {
- background: rgba(255, 255, 255, 0.1);
+ background: var(--color-bg-glass-strong);
}
.btnGhost:hover .btnName {
- color: rgba(255, 255, 255, 0.9);
+ color: var(--color-text-primary);
}
/* Sub-text below buttons */
@@ -131,14 +133,15 @@
font-family: 'Space Grotesk', sans-serif;
font-size: 12px;
font-weight: 300;
- color: rgba(120, 220, 200, 0.5);
+ color: var(--color-mint-accent);
+ opacity: 0.65;
}
.btnSubGhost {
font-family: 'Space Grotesk', sans-serif;
font-size: 12px;
font-weight: 300;
- color: rgba(255, 255, 255, 0.35);
+ color: var(--color-text-tertiary);
}
/* ── Easter egg ── */
@@ -152,17 +155,19 @@
font-size: 13px;
font-weight: 300;
font-style: italic;
- color: rgba(255, 255, 255, 0.06);
+ color: var(--color-text-quaternary);
+ opacity: 0.45;
letter-spacing: 0.05em;
cursor: default;
- transition: color 2s ease;
+ transition: color 2s ease, opacity 2s ease;
white-space: nowrap;
text-align: center;
animation: fadeUp 1.2s ease-out 0.8s both;
}
.easter:hover {
- color: rgba(255, 255, 255, 0.25);
+ color: var(--color-text-tertiary);
+ opacity: 1;
}
/* ── Air Spark full-screen overlay ── */
@@ -174,7 +179,7 @@
align-items: center;
justify-content: center;
cursor: pointer;
- background: rgba(0, 0, 0, 0.5);
+ background: var(--color-overlay-soft);
backdrop-filter: blur(30px);
-webkit-backdrop-filter: blur(30px);
animation: sparkBgIn 0.5s ease-out both;
@@ -189,7 +194,7 @@
to {
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-size: clamp(40px, 5vw, 64px);
font-weight: 300;
- color: #ffffff;
+ color: var(--color-text-primary);
line-height: 1.2;
text-align: center;
}
@@ -229,7 +234,7 @@
font-family: 'Space Grotesk', sans-serif;
font-size: 16px;
font-weight: 300;
- color: rgba(255, 255, 255, 0.5);
+ color: var(--color-text-secondary);
margin-top: 20px;
}
@@ -253,14 +258,14 @@
-webkit-tap-highlight-color: transparent;
user-select: none;
-webkit-user-select: none;
- color: rgba(255, 255, 255, 0.2);
+ color: var(--color-text-quaternary);
cursor: pointer;
transition: color 0.3s;
animation: fadeUp 1.2s ease-out 0.8s both;
}
.musicBtn:hover {
- color: rgba(255, 255, 255, 0.5);
+ color: var(--color-text-tertiary);
}
diff --git a/web/src/pages/LandingPage.tsx b/web/src/pages/LandingPage.tsx
index 61517cd..cdd0758 100644
--- a/web/src/pages/LandingPage.tsx
+++ b/web/src/pages/LandingPage.tsx
@@ -111,6 +111,8 @@ export function LandingPage({ autoLogin }: Props) {
}, [playing]);
return (
+ // V2: 跟随全局主题切换。LandingPage 浅色化 = AuroraCanvas + LoginModal 都跟随。
+ // 薄荷绿在浅色下加深为 teal (#0d9488),保证对比度。
{/* Layer 1-4: Aurora background */}
diff --git a/web/src/pages/LoginRecordsPage.module.css b/web/src/pages/LoginRecordsPage.module.css
index c1f8472..aa84b99 100644
--- a/web/src/pages/LoginRecordsPage.module.css
+++ b/web/src/pages/LoginRecordsPage.module.css
@@ -8,7 +8,7 @@
}
.searchInput:focus { border-color: var(--color-primary); }
.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; }
.refreshBtn {
padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s;
@@ -19,16 +19,17 @@
.tableWrapper {
background: var(--color-bg-card); border: 1px solid var(--color-border-card);
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 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: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); }
.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; }
.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: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; }
diff --git a/web/src/pages/ProfilePage.module.css b/web/src/pages/ProfilePage.module.css
index 441f727..335624c 100644
--- a/web/src/pages/ProfilePage.module.css
+++ b/web/src/pages/ProfilePage.module.css
@@ -85,8 +85,8 @@
/* Warning banners */
.warningBanner {
padding: 10px 16px;
- background: rgba(243, 156, 18, 0.12);
- border: 1px solid rgba(243, 156, 18, 0.3);
+ background: var(--color-warning-bg);
+ border: 1px solid var(--color-warning-border);
border-radius: var(--radius-card);
color: var(--color-warning);
font-size: 13px;
@@ -96,8 +96,8 @@
.dangerBanner {
padding: 10px 16px;
- background: rgba(231, 76, 60, 0.12);
- border: 1px solid rgba(231, 76, 60, 0.3);
+ background: var(--color-danger-bg);
+ border: 1px solid var(--color-danger-border);
border-radius: var(--radius-card);
color: var(--color-danger);
font-size: 13px;
@@ -135,6 +135,7 @@
display: flex;
flex-direction: column;
justify-content: center;
+ box-shadow: var(--shadow-card-light);
}
.quotaLabel {
@@ -153,7 +154,7 @@
.progressBar {
width: 100%;
height: 6px;
- background: #1e1e2a;
+ background: var(--color-bg-placeholder);
border-radius: 3px;
overflow: hidden;
}
@@ -212,7 +213,7 @@
.tabActive {
background: var(--color-primary);
- color: #fff;
+ color: var(--color-on-primary);
}
.sparklineWrapper {
@@ -220,6 +221,7 @@
border: 1px solid var(--color-border-card);
border-radius: var(--radius-card);
padding: 16px;
+ box-shadow: var(--shadow-card-light);
}
/* Records */
@@ -245,10 +247,11 @@
border: 1px solid var(--color-border-card);
border-radius: var(--radius-card);
transition: border-color 0.2s;
+ box-shadow: var(--shadow-card-light);
}
.recordItem:hover {
- border-color: rgba(255, 255, 255, 0.1);
+ border-color: var(--color-border-card);
}
.recordLeft {
@@ -290,7 +293,7 @@
color: var(--color-text-secondary);
font-size: 12px;
padding: 2px 8px;
- background: rgba(255, 255, 255, 0.04);
+ background: var(--color-bg-upload);
border-radius: 4px;
}
@@ -302,22 +305,22 @@
.queued {
color: var(--color-text-secondary);
- background: rgba(255, 255, 255, 0.04);
+ background: var(--color-bg-upload);
}
.processing {
color: var(--color-primary);
- background: rgba(0, 184, 230, 0.1);
+ background: var(--color-info-bg-hover);
}
.completed {
color: var(--color-success);
- background: rgba(0, 184, 148, 0.1);
+ background: var(--color-success-bg-hover);
}
.failed {
color: var(--color-danger);
- background: rgba(231, 76, 60, 0.1);
+ background: var(--color-danger-bg-hover);
}
.loadMoreBtn {
@@ -397,7 +400,7 @@
.modalOverlay {
position: fixed;
inset: 0;
- background: rgba(0, 0, 0, 0.6);
+ background: var(--color-modal-overlay);
display: flex;
align-items: center;
justify-content: center;
@@ -405,7 +408,7 @@
}
.modal {
- background: #16161e;
+ background: var(--color-bg-modal-elevated);
border: 1px solid var(--color-border-card);
border-radius: var(--radius-card);
padding: 24px;
@@ -475,7 +478,7 @@
background: var(--color-primary);
border: none;
border-radius: 8px;
- color: #fff;
+ color: var(--color-on-primary);
font-size: 13px;
cursor: pointer;
}
diff --git a/web/src/pages/ProfilePage.tsx b/web/src/pages/ProfilePage.tsx
index 6a7acc5..51205b9 100644
--- a/web/src/pages/ProfilePage.tsx
+++ b/web/src/pages/ProfilePage.tsx
@@ -11,6 +11,8 @@ import type { ProfileOverview, AdminRecord } from '../types';
import { showToast } from '../components/Toast';
import styles from './ProfilePage.module.css';
import { AxiosError } from 'axios';
+import { c } from '../lib/themeColor';
+import { useThemeStore } from '../store/theme';
echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]);
@@ -31,6 +33,7 @@ export function ProfilePage() {
const [confirmPw, setConfirmPw] = useState('');
const [pwError, setPwError] = useState('');
const [pwSaving, setPwSaving] = useState(false);
+ const theme = useThemeStore((s) => s.theme);
const fetchOverview = useCallback(async () => {
try {
@@ -110,9 +113,9 @@ export function ProfilePage() {
const sparklineOption: echarts.EChartsCoreOption = {
tooltip: {
trigger: 'axis',
- backgroundColor: '#1e1e2a',
- borderColor: '#2a2a38',
- textStyle: { color: '#e2e8f0', fontSize: 12 },
+ backgroundColor: c('tooltip-bg'),
+ borderColor: c('border-modal'),
+ textStyle: { color: c('text-light'), fontSize: 12 },
},
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)) },
@@ -122,11 +125,11 @@ export function ProfilePage() {
data: overview.daily_trend.map((d) => d.seconds),
smooth: true,
symbol: 'none',
- lineStyle: { color: '#00b8e6', width: 2 },
+ lineStyle: { color: c('info'), width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
- { offset: 0, color: 'rgba(0, 184, 230, 0.3)' },
- { offset: 1, color: 'rgba(0, 184, 230, 0.02)' },
+ { offset: 0, color: c('chart-area-from') },
+ { offset: 1, color: c('chart-area-to') },
]),
},
}],
@@ -208,7 +211,7 @@ export function ProfilePage() {
-
+
diff --git a/web/src/pages/RecordsPage.module.css b/web/src/pages/RecordsPage.module.css
index c6806a1..a45d9d3 100644
--- a/web/src/pages/RecordsPage.module.css
+++ b/web/src/pages/RecordsPage.module.css
@@ -5,7 +5,7 @@
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;
}
-.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; }
.searchInput {
@@ -23,35 +23,36 @@
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;
}
-.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; }
.tableWrapper {
background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); overflow-x: auto;
+ box-shadow: var(--shadow-card-light);
}
.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 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: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); }
.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; }
.statusBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; }
-.completed { background: rgba(0, 184, 148, 0.15); color: var(--color-success); }
-.failed { background: rgba(231, 76, 60, 0.15); color: var(--color-danger); }
+.completed { background: var(--color-success-bg); color: var(--color-success); }
+.failed { background: var(--color-danger-bg); color: var(--color-danger); }
.statusCell { position: relative; }
.statusCell:hover .errorTooltip { opacity: 1; visibility: visible; transform: translateY(0); }
.errorTooltip {
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;
max-width: 360px; width: max-content;
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; }
.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; } }
@@ -65,4 +66,4 @@
}
.pageButtons button:hover:not(:disabled) { background: var(--color-sidebar-hover); color: var(--color-text-primary); }
.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; }
diff --git a/web/src/pages/SettingsPage.module.css b/web/src/pages/SettingsPage.module.css
index 1ac5010..ceca815 100644
--- a/web/src/pages/SettingsPage.module.css
+++ b/web/src/pages/SettingsPage.module.css
@@ -6,6 +6,7 @@
.card {
background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); padding: 24px;
+ box-shadow: var(--shadow-card-light);
}
.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; }
@@ -24,7 +25,7 @@
.saveBtn {
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:disabled { opacity: 0.5; cursor: not-allowed; }
@@ -38,7 +39,7 @@
}
.slider::before {
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::before { transform: translateX(20px); }
diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx
index 18eb9c8..1d4b80b 100644
--- a/web/src/pages/SettingsPage.tsx
+++ b/web/src/pages/SettingsPage.tsx
@@ -297,7 +297,7 @@ export function SettingsPage() {
setTimeout(() => { ta.focus(); ta.setSelectionRange(cursorPos, cursorPos); }, 0);
}}
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,
color: 'var(--color-text-secondary)', cursor: 'pointer',
}}
@@ -310,7 +310,7 @@ export function SettingsPage() {
onClick={() => setPreviewAnnouncement(!previewAnnouncement)}
style={{
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)'}`,
borderRadius: 4,
color: previewAnnouncement ? 'var(--color-primary)' : 'var(--color-text-secondary)',
diff --git a/web/src/pages/TeamDashboardPage.tsx b/web/src/pages/TeamDashboardPage.tsx
index 846d352..c667f70 100644
--- a/web/src/pages/TeamDashboardPage.tsx
+++ b/web/src/pages/TeamDashboardPage.tsx
@@ -7,6 +7,8 @@ import { CanvasRenderer } from 'echarts/renderers';
import { teamApi } from '../lib/api';
import type { TeamInfo, TeamStats } from '../types';
import { showToast } from '../components/Toast';
+import { c } from '../lib/themeColor';
+import { useThemeStore } from '../store/theme';
import styles from './DashboardPage.module.css';
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 [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
+ const theme = useThemeStore((s) => s.theme);
const fetchData = useCallback(async () => {
try {
@@ -62,9 +65,9 @@ export function TeamDashboardPage() {
const trendOption: echarts.EChartsCoreOption = {
tooltip: {
trigger: 'axis',
- backgroundColor: 'rgba(13, 13, 26, 0.95)',
- borderColor: 'rgba(255, 255, 255, 0.10)',
- textStyle: { color: '#f1f0ff', fontSize: 12 },
+ backgroundColor: c('tooltip-bg'),
+ borderColor: c('tooltip-border'),
+ textStyle: { color: c('text-primary'), fontSize: 12 },
formatter: (params: unknown) => {
const p = (params as { name: string; value: number }[])[0];
return `${p.name} 消费: ¥${p.value.toFixed(2)}`;
@@ -74,27 +77,27 @@ export function TeamDashboardPage() {
xAxis: {
type: 'category',
data: stats.daily_trend.map((d) => d.date.slice(5)),
- axisLabel: { color: '#8b8ea8', fontSize: 11 },
- axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } },
+ axisLabel: { color: c('text-secondary'), fontSize: 11 },
+ axisLine: { lineStyle: { color: c('chart-axis') } },
},
yAxis: {
type: 'value',
- axisLabel: { color: '#8b8ea8', fontSize: 11 },
- splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } },
+ axisLabel: { color: c('text-secondary'), fontSize: 11 },
+ splitLine: { lineStyle: { color: c('chart-grid') } },
},
dataZoom: [{ type: 'inside', start: 0, end: 100 }],
series: [{
type: 'line',
data: stats.daily_trend.map((d) => d.cost ?? d.seconds),
smooth: true,
- lineStyle: { color: '#6c63ff', width: 2 },
+ lineStyle: { color: c('primary'), width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
- { offset: 0, color: 'rgba(108, 99, 255, 0.25)' },
- { offset: 1, color: 'rgba(108, 99, 255, 0.02)' },
+ { offset: 0, color: c('chart-area-from') },
+ { offset: 1, color: c('chart-area-to') },
]),
},
- itemStyle: { color: '#6c63ff' },
+ itemStyle: { color: c('primary') },
}],
};
@@ -103,21 +106,21 @@ export function TeamDashboardPage() {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
- backgroundColor: 'rgba(13, 13, 26, 0.95)',
- borderColor: 'rgba(255, 255, 255, 0.10)',
- textStyle: { color: '#f1f0ff', fontSize: 12 },
+ backgroundColor: c('tooltip-bg'),
+ borderColor: c('tooltip-border'),
+ textStyle: { color: c('text-primary'), fontSize: 12 },
},
grid: { left: 80, right: 40, top: 10, bottom: 20 },
xAxis: {
type: 'value',
- axisLabel: { color: '#8b8ea8', fontSize: 11 },
- splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } },
+ axisLabel: { color: c('text-secondary'), fontSize: 11 },
+ splitLine: { lineStyle: { color: c('chart-grid') } },
},
yAxis: {
type: 'category',
data: sortedMembers.map((m) => m.username),
- axisLabel: { color: '#8b8ea8', fontSize: 12 },
- axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } },
+ axisLabel: { color: c('text-secondary'), fontSize: 12 },
+ axisLine: { lineStyle: { color: c('chart-axis') } },
},
series: [{
type: 'bar',
@@ -125,15 +128,15 @@ export function TeamDashboardPage() {
barWidth: 16,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
- { offset: 0, color: '#6c63ff' },
- { offset: 1, color: '#8b5cf6' },
+ { offset: 0, color: c('primary') },
+ { offset: 1, color: c('primary-2') },
]),
borderRadius: [0, 4, 4, 0],
},
label: {
show: true,
position: 'right',
- color: '#8b8ea8',
+ color: c('text-secondary'),
fontSize: 11,
formatter: (p: { value: number }) => `¥${p.value.toFixed(2)}`,
},
@@ -156,14 +159,14 @@ export function TeamDashboardPage() {
diff --git a/web/src/pages/TeamMembersPage.tsx b/web/src/pages/TeamMembersPage.tsx
index 9538898..f0f2f9f 100644
--- a/web/src/pages/TeamMembersPage.tsx
+++ b/web/src/pages/TeamMembersPage.tsx
@@ -146,16 +146,16 @@ export function TeamMembersPage() {
{m.username}
{m.is_team_owner ? (
- 主管理员
+ 主管理员
) : m.is_team_admin ? (
- 副管理员
+ 副管理员
) : (
成员
)}
diff --git a/web/src/pages/TeamsPage.module.css b/web/src/pages/TeamsPage.module.css
index ae857f5..6a8f0f5 100644
--- a/web/src/pages/TeamsPage.module.css
+++ b/web/src/pages/TeamsPage.module.css
@@ -13,34 +13,35 @@
background: transparent; border: 1px solid var(--color-border-card); color: var(--color-text-secondary);
}
.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; }
.tableWrapper {
background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); overflow-x: auto;
+ box-shadow: var(--shadow-card-light);
}
.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 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: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; }
.statusBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; }
-.active { background: rgba(0, 184, 148, 0.15); color: var(--color-success); }
-.disabled { background: rgba(231, 76, 60, 0.15); color: var(--color-danger); }
+.active { background: var(--color-success-bg); color: var(--color-success); }
+.disabled { background: var(--color-danger-bg); color: var(--color-danger); }
.actions { display: flex; gap: 6px; }
.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:hover { background: rgba(0, 184, 230, 0.1); }
-.adminBtn { background: transparent; border: 1px solid #a78bfa; color: #a78bfa; }
-.adminBtn:hover { background: rgba(167, 139, 250, 0.1); }
+.topupBtn:hover { background: var(--color-info-bg-hover); }
+.adminBtn { background: transparent; border: 1px solid var(--color-purple-accent); color: var(--color-purple-accent); }
+.adminBtn:hover { background: var(--color-purple-bg-hover); }
.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: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; }
.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; }
/* Modal */
-.modalOverlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); 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; }
+.modalOverlay { position: fixed; inset: 0; background: var(--color-modal-overlay); display: flex; align-items: center; justify-content: center; z-index: 300; }
+.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; }
.formGroup { margin-bottom: 16px; }
.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); }
.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; }
-.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 .formGroup { flex: 1; }
.formError { color: var(--color-danger); font-size: 13px; margin-bottom: 12px; }
@@ -71,7 +72,7 @@
.detailOverlay {
position: fixed;
inset: 0;
- background: rgba(0, 0, 0, 0.65);
+ background: var(--color-modal-overlay);
display: flex;
align-items: center;
justify-content: center;
@@ -85,10 +86,10 @@
}
.detailModal {
- background: rgba(22, 22, 30, 0.92);
- backdrop-filter: blur(24px) saturate(180%);
- -webkit-backdrop-filter: blur(24px) saturate(180%);
- border: 1px solid rgba(255, 255, 255, 0.08);
+ 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-modal-soft);
border-radius: 16px;
width: 1280px;
max-width: 96vw;
@@ -96,7 +97,7 @@
max-height: 90vh;
display: flex;
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;
}
@@ -111,7 +112,7 @@
justify-content: space-between;
align-items: center;
padding: 20px 28px;
- border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+ border-bottom: 1px solid var(--color-border-soft);
flex-shrink: 0;
}
@@ -132,16 +133,16 @@
width: 36px;
height: 36px;
border-radius: 8px;
- background: rgba(255, 255, 255, 0.06);
+ background: var(--color-bg-card);
border: none;
- color: rgba(255, 255, 255, 0.5);
+ color: var(--color-text-on-glass-soft);
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.detailClose:hover {
- color: #fff;
- background: rgba(255, 255, 255, 0.12);
+ color: var(--color-text-primary);
+ background: var(--color-bg-glass-hover);
}
/* ── Body ── */
@@ -163,26 +164,26 @@
display: flex;
flex-direction: column;
gap: 8px;
- background: rgba(255, 255, 255, 0.04);
- border: 1px solid rgba(255, 255, 255, 0.06);
+ background: var(--color-bg-upload);
+ border: 1px solid var(--color-border-soft);
border-radius: 10px;
padding: 16px 18px;
transition: background 0.15s;
}
.detailItem:hover {
- background: rgba(255, 255, 255, 0.06);
+ background: var(--color-bg-card);
}
.detailLabel {
- color: #8b8ea8;
+ color: var(--color-text-secondary);
font-size: 12px;
font-weight: 500;
line-height: 1;
}
.detailValue {
- color: #f1f0ff;
+ color: var(--color-text-primary);
font-size: 18px;
font-weight: 600;
display: flex;
@@ -197,9 +198,9 @@
width: 26px;
height: 26px;
border-radius: 6px;
- background: rgba(255, 255, 255, 0.06);
+ background: var(--color-bg-card);
border: none;
- color: rgba(255, 255, 255, 0.4);
+ color: var(--color-text-on-glass-faint);
cursor: pointer;
transition: color 0.15s, background 0.15s;
flex-shrink: 0;
@@ -207,7 +208,7 @@
.editPoolBtn:hover {
color: var(--color-primary);
- background: rgba(0, 184, 230, 0.12);
+ background: var(--color-info-bg-soft);
}
/* ── Members section ── */
@@ -218,12 +219,12 @@
margin-top: 20px;
margin-bottom: 14px;
padding-bottom: 12px;
- border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+ border-bottom: 1px solid var(--color-border-soft);
}
.memberTableWrapper {
- background: rgba(255, 255, 255, 0.03);
- border: 1px solid rgba(255, 255, 255, 0.06);
+ background: var(--color-bg-upload);
+ border: 1px solid var(--color-border-soft);
border-radius: 10px;
overflow: hidden;
}
@@ -237,28 +238,28 @@
.memberTable th {
padding: 12px 18px;
text-align: left;
- color: #8b8ea8;
+ color: var(--color-text-secondary);
font-weight: 500;
font-size: 13px;
- border-bottom: 1px solid rgba(255, 255, 255, 0.06);
- background: rgba(255, 255, 255, 0.02);
+ border-bottom: 1px solid var(--color-border-soft);
+ background: var(--color-bg-row-hover);
white-space: nowrap;
}
.memberTable td {
padding: 14px 18px;
- color: #f1f0ff;
- border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+ color: var(--color-text-primary);
+ border-bottom: 1px solid var(--color-border-row);
}
.memberTable tr:last-child td { border-bottom: none; }
.memberTable tr:hover td {
- background: rgba(255, 255, 255, 0.04);
+ background: var(--color-bg-upload);
}
.ownerBadge {
- background: rgba(0, 184, 230, 0.15);
+ background: var(--color-info-bg);
color: var(--color-primary, #00b8e6);
padding: 3px 10px;
border-radius: 6px;
@@ -267,8 +268,8 @@
}
.adminBadge {
- background: rgba(167, 139, 250, 0.15);
- color: #a78bfa;
+ background: var(--color-purple-bg);
+ color: var(--color-purple-accent);
padding: 3px 10px;
border-radius: 6px;
font-size: 12px;
diff --git a/web/src/pages/TeamsPage.tsx b/web/src/pages/TeamsPage.tsx
index cc15bde..1745ed9 100644
--- a/web/src/pages/TeamsPage.tsx
+++ b/web/src/pages/TeamsPage.tsx
@@ -744,7 +744,7 @@ export function TeamsPage() {
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}
@@ -815,7 +815,7 @@ export function TeamsPage() {
{m.username}
diff --git a/web/src/pages/UsersPage.module.css b/web/src/pages/UsersPage.module.css
index 3892f5c..3074e5c 100644
--- a/web/src/pages/UsersPage.module.css
+++ b/web/src/pages/UsersPage.module.css
@@ -15,36 +15,37 @@
.searchBtn, .refreshBtn {
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; }
.refreshBtn { background: transparent; border: 1px solid var(--color-border-card); color: var(--color-text-secondary); }
.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; }
.tableWrapper {
background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); overflow-x: auto;
+ box-shadow: var(--shadow-card-light);
}
.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 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: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; }
.statusBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; }
-.active { background: rgba(0, 184, 148, 0.15); color: var(--color-success); }
-.disabled { background: rgba(231, 76, 60, 0.15); color: var(--color-danger); }
+.active { background: var(--color-success-bg); color: var(--color-success); }
+.disabled { background: var(--color-danger-bg); color: var(--color-danger); }
.actions { display: flex; gap: 6px; }
.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: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: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: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; }
.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: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 */
-.modalOverlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); 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; }
+.modalOverlay { position: fixed; inset: 0; background: var(--color-modal-overlay); display: flex; align-items: center; justify-content: center; z-index: 300; }
+.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; }
.formGroup { margin-bottom: 16px; }
.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); }
.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; }
-.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 .formGroup { flex: 1; }
.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; }
/* 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 {
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;
animation: slideIn 0.2s ease;
}
@@ -103,7 +104,7 @@
.recordSeconds { color: var(--color-primary); font-weight: 600; font-size: 14px; }
.recordMode { color: var(--color-text-secondary); font-size: 12px; }
.recordStatus { font-size: 12px; padding: 1px 6px; border-radius: 4px; }
-.completed { background: rgba(0, 184, 148, 0.15); color: var(--color-success); }
-.failed { background: rgba(231, 76, 60, 0.15); color: var(--color-danger); }
-.queued, .processing { background: rgba(0, 184, 230, 0.15); color: var(--color-primary); }
+.completed { background: var(--color-success-bg); color: var(--color-success); }
+.failed { background: var(--color-danger-bg); color: var(--color-danger); }
+.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; }
diff --git a/web/src/store/theme.ts b/web/src/store/theme.ts
new file mode 100644
index 0000000..4e8a092
--- /dev/null
+++ b/web/src/store/theme.ts
@@ -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((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 });
+ },
+}));
diff --git a/web/src/types/index.ts b/web/src/types/index.ts
index 2761b8e..06ec2f4 100644
--- a/web/src/types/index.ts
+++ b/web/src/types/index.ts
@@ -217,6 +217,7 @@ export interface AdminRecord {
duration?: number;
seed?: number;
ark_task_id?: string;
+ result_url?: string;
}
export interface SystemSettings {
diff --git a/web/test/modal-interaction.mjs b/web/test/modal-interaction.mjs
new file mode 100644
index 0000000..d0aa984
--- /dev/null
+++ b/web/test/modal-interaction.mjs
@@ -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); });
diff --git a/web/test/modal-preview.mjs b/web/test/modal-preview.mjs
new file mode 100644
index 0000000..9564bf3
--- /dev/null
+++ b/web/test/modal-preview.mjs
@@ -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); });
diff --git a/web/test/theme-screenshots-v2.mjs b/web/test/theme-screenshots-v2.mjs
new file mode 100644
index 0000000..c2eba4f
--- /dev/null
+++ b/web/test/theme-screenshots-v2.mjs
@@ -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/__.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);
+});
diff --git a/web/test/theme-screenshots.mjs b/web/test/theme-screenshots.mjs
new file mode 100644
index 0000000..3467ef4
--- /dev/null
+++ b/web/test/theme-screenshots.mjs
@@ -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/__.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);
+});
diff --git a/web/test/v2-smoke.mjs b/web/test/v2-smoke.mjs
new file mode 100644
index 0000000..017ebc0
--- /dev/null
+++ b/web/test/v2-smoke.mjs
@@ -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);
+});