seaislee1209 f0f47e8368 feat(theme): 亮色主题切换完整实现 — dark/light 双套 var + Sidebar 切换 + 浅色色板
Stage 1 (var 化, 350 处): 425 处硬编码颜色 → CSS var, 涉及 49 个 tsx/css module 文件,
   按 hot files (DashboardPage/TeamDashboardPage/RecordDetailModal/ReferenceList) →
   Modal/Asset/Profile/Login → 生成页家族/管理后台/公共 UI 三波 8 个 sub-agent 并行处理。
   index.css :root 加 ~70 个新 var (modal/text 层级/状态色 bg 变体/chart/mention pill 等)。
Stage 2 (双套 var): :root 保留 DARK 默认值, [data-theme="light"] 覆盖 ~95 个 token。
   浅色色板按 Vercel Geist (#fafafa / #171717 / shadow-border) + Linear Light surface 分层规范,
   主色 #6c63ff → #5048cc 加深 18% 满足 WCAG AA。aurora 极光在 light 下 display:none。
Stage 3 (切换机制): 新建 store/theme.ts (Zustand + localStorage 持久化),
   Sidebar 加月亮/太阳 SVG 切换按钮 (位于头像上方),
   DashboardPage/TeamDashboardPage/ProfilePage 的 ECharts 配 key={theme} 强制重渲染。
Stage 4 (微调): LandingPage 强制 data-theme="dark" 保持品牌识别 (登录流程一直深色),
   sidebar bg / card bg / border 在浅色下加深 0.02 提升轮廓辨识度。
Stage 5 (验证): Playwright 头无浏览器自动登录 admin + screenshot_user, 截深/浅各 12 个页面 = 24 张
   到 docs/screenshots/ (本地档, .gitignore 排除 png 不入库)。
   vitest 71fail/162pass 与改造前基线完全一致, 无新增回归。

完成报告: docs/todo/亮色主题切换-完成报告.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:10:35 +08:00

174 lines
7.2 KiB
TypeScript

import { useRef, useState, useCallback, type DragEvent } from 'react';
import { useInputBarStore } from '../store/inputBar';
import { UniversalUpload } from './UniversalUpload';
import { KeyframeUpload } from './KeyframeUpload';
import { PromptInput } from './PromptInput';
import { Toolbar } from './Toolbar';
import { AssetLibraryModal } from './AssetLibraryModal';
import { showToast } from './Toast';
import styles from './InputBar.module.css';
export function InputBar({ scrollBottomBtn }: { scrollBottomBtn?: React.ReactNode }) {
const mode = useInputBarStore((s) => s.mode);
const addReferences = useInputBarStore((s) => s.addReferences);
const setFirstFrame = useInputBarStore((s) => s.setFirstFrame);
const barRef = useRef<HTMLDivElement>(null);
const handleDragOver = useCallback((e: DragEvent) => {
e.preventDefault();
// 只有外部文件拖入时才显示蓝色边框(内部 mention 标签拖拽不触发)
if (e.dataTransfer.types.includes('Files') && barRef.current) {
barRef.current.style.borderColor = 'var(--color-info)';
}
}, []);
const handleDragLeave = useCallback(() => {
if (barRef.current) {
barRef.current.style.borderColor = 'var(--color-border-modal)';
}
}, []);
const handleDrop = useCallback((e: DragEvent) => {
e.preventDefault();
if (barRef.current) {
barRef.current.style.borderColor = 'var(--color-border-modal)';
}
const IMAGE_MAX = 30 * 1024 * 1024;
const VIDEO_MAX = 50 * 1024 * 1024;
const AUDIO_MAX = 15 * 1024 * 1024;
const files = Array.from(e.dataTransfer.files).filter(
(f) => f.type.startsWith('image/') || f.type.startsWith('video/') || f.type.startsWith('audio/')
);
if (!files.length) return;
const valid: File[] = [];
for (const f of files) {
// Format validation
if (f.type.startsWith('video/') && f.type !== 'video/mp4' && f.type !== 'video/quicktime') {
showToast('仅支持 MP4 和 MOV 格式的视频');
continue;
}
if (f.type.startsWith('audio/') && f.type !== 'audio/mpeg' && f.type !== 'audio/wav') {
showToast('仅支持 MP3 和 WAV 格式的音频');
continue;
}
// Size validation
let limit: number;
let limitLabel: string;
if (f.type.startsWith('video/')) {
limit = VIDEO_MAX;
limitLabel = '视频文件不能超过50MB';
} else if (f.type.startsWith('audio/')) {
limit = AUDIO_MAX;
limitLabel = '音频文件不能超过15MB';
} else {
limit = IMAGE_MAX;
limitLabel = '图片文件不能超过30MB';
}
if (f.size > limit) {
showToast(limitLabel);
} else {
valid.push(f);
}
}
if (!valid.length) return;
if (mode === 'universal') {
addReferences(valid);
} else {
const imageFiles = valid.filter((f) => f.type.startsWith('image/'));
if (imageFiles.length > 0) {
setFirstFrame(imageFiles[0]);
}
}
}, [mode, addReferences, setFirstFrame]);
const [assetModalOpen, setAssetModalOpen] = useState(false);
const searchMode = useInputBarStore((s) => s.searchMode);
const setSearchMode = useInputBarStore((s) => s.setSearchMode);
const seed = useInputBarStore((s) => s.seed);
const seedEnabled = useInputBarStore((s) => s.seedEnabled);
const setSeed = useInputBarStore((s) => s.setSeed);
const setSeedEnabled = useInputBarStore((s) => s.setSeedEnabled);
const references = useInputBarStore((s) => s.references);
const editorHtml = useInputBarStore((s) => s.editorHtml);
const firstFrame = useInputBarStore((s) => s.firstFrame);
const lastFrame = useInputBarStore((s) => s.lastFrame);
// 联网搜索暂未开放
const searchDisabled = true;
return (
<div className={styles.wrapper}>
<div className={styles.container}>
{/* 素材库 + 联网搜索按钮 — 输入框上方 */}
<div style={{ display: 'flex', gap: 8, marginBottom: 6, paddingLeft: 4 }}>
<button
onClick={() => setAssetModalOpen(true)}
style={{
background: 'transparent', border: '1px solid var(--color-border-card)',
borderRadius: 6, padding: '4px 12px', fontSize: 12,
color: 'var(--color-text-secondary)', cursor: 'pointer',
transition: 'all 0.15s',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--color-primary)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-primary)'; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--color-border-card)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-text-secondary)'; }}
>
</button>
<button
onClick={() => { if (!searchDisabled) setSearchMode(searchMode === 'smart' ? 'off' : 'smart'); }}
title={searchDisabled ? '联网搜索仅支持纯文生视频' : ''}
style={{
background: searchMode === 'smart' && !searchDisabled ? 'var(--color-mention-bg)' : 'transparent',
border: `1px solid ${searchMode === 'smart' && !searchDisabled ? 'var(--color-primary)' : 'var(--color-border-card)'}`,
borderRadius: 6, padding: '4px 12px', fontSize: 12,
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',
opacity: searchDisabled ? 0.5 : 1,
}}
onMouseEnter={(e) => { if (!searchDisabled && searchMode !== 'smart') { (e.currentTarget as HTMLElement).style.borderColor = 'var(--color-primary)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-primary)'; } }}
onMouseLeave={(e) => { if (!searchDisabled && searchMode !== 'smart') { (e.currentTarget as HTMLElement).style.borderColor = 'var(--color-border-card)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-text-secondary)'; } }}
>
</button>
<button
disabled
style={{
background: 'transparent',
border: '1px solid var(--color-border-card)',
borderRadius: 6, padding: '4px 12px', fontSize: 12,
color: 'var(--color-btn-send-disabled)', cursor: 'not-allowed', transition: 'all 0.15s',
opacity: 0.5,
}}
>
</button>
{scrollBottomBtn}
</div>
<div
ref={barRef}
className={styles.bar}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Upper area: Upload + Prompt */}
<div className={styles.inputArea}>
{mode === 'universal' ? <UniversalUpload /> : <KeyframeUpload />}
<PromptInput />
</div>
{/* Divider */}
<div className={styles.divider} />
{/* Toolbar */}
<Toolbar />
</div>
</div>
<AssetLibraryModal open={assetModalOpen} onClose={() => setAssetModalOpen(false)} />
</div>
);
}