video-shuoshan/web/src/components/AuroraCanvas.tsx
seaislee1209 f8a39d55c7 feat(theme): 浅色主题 V2 — 玻璃质感重做 + LandingPage 浅色化 + 双语言系统
Phase A: index.css 完全重写 [data-theme="light"] block
  - 玻璃方向修正: bg-card 从 rgba(0,0,0,0.05) 黑透明 → 双 token 拆分
    --color-bg-card: #ffffff 实体白 (admin 卡)
    --color-bg-glass: rgba(255,255,255,0.65) 透明白玻璃 (sidebar/modal/banner)
  - Aurora 浅色不再 display:none, 改 pastel 紫蓝桃 0.20-0.32 alpha
  - Inset highlight 方向反转: 浅色用 rgba(255,255,255,0.50) 白高光 (玻璃顶边标志)
  - Backdrop-filter 五档标准: --bf-glass-sm/md/lg/xl (12-40px + saturate 140-180%)
  - Multi-layer shadow: --shadow-card-light (2 stops) + --shadow-glass-light (3 stops + inset)
  - 暖调 chip: --color-chip-warm-* GitBook 公告风格
  - 文字主色: #171823 微紫 → #171717 Vercel Black

Phase B: LandingPage + AuroraCanvas 浅色化
  - 移除 LandingPage 的 data-theme="dark" 强制 (V1 的回避)
  - LandingPage.module.css 21 处颜色全 var 化
  - AuroraCanvas: 订阅 useThemeStore, 新 LIGHT_ORBS 数组 pastel 紫蓝桃,
    vignette 浅色用白色, grain opacity 减半

Phase C: 13 个玻璃面升级 (3 sub-agent 并行)
  - Modal 类 (Login/ForceChange/VideoDetail.infoPanel/RecordDetail/AssetLibrary/
    Announcement/Confirm/TeamsPage.detailModal): 接入 bg-modal-glass +
    bf-glass-lg/xl + shadow-glass-light (含 inset highlight)
  - Bar/Dropdown/Toast (AnnouncementBanner/Toast/Dropdown/Select/DatePicker):
    bg-glass-strong + bf-glass-md + inset-highlight
  - Sidebar + 生成页 (Sidebar/PromptInput/GenerationCard): glass + 顶边白高光
  - AnnouncementBanner 写双套独立 [data-theme] 规则 (CSS gradient 内不能 var alpha)

Phase D: admin 实体卡 multi-layer shadow (13 处, 1 sub-agent)
  - DashboardPage / TeamsPage / UsersPage / RecordsPage / AdminAssetsPage /
    LoginRecordsPage / AuditLogsPage / ProfilePage / SettingsPage
    的 .statCard / .tableWrapper / .chartWrapper / .accordionItem 等
    加 var(--shadow-card-light) 双层柔阴影

AdminLayout 修复 (V1 漏的):
  - .layout 改 transparent, 让 AmbientBackground pastel aurora 在主区透出
  - .sidebar 加 bf-glass-md + inset highlight + 立体阴影

LoginModal / ForceChangePassword 残留 mint 清理:
  - submitBtn bg/border/color 用 mint-accent var, 字重 500→600 + 字距 0.04em
  - input:focus border 用 var(--color-mint-accent)
  - 加 bf-glass-sm + inset highlight

验证:
  - TS 编译过
  - vitest 71 fail / 162 pass 与 V1 基线完全一致, 无新增回归
  - 24 张 V2 截图位于 docs/screenshots/v2/ (本地, .gitignore 排除 png)

完成报告: docs/todo/亮色主题切换V2-完成报告.md
V2 plan: docs/todo/亮色主题切换V2.md
视觉对齐稿: docs/todo/showcase.html

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

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

265 lines
9.0 KiB
TypeScript

import { useRef, useEffect, useCallback } from 'react';
import { useThemeStore } from '../store/theme';
/**
* Aurora background — 5 large diffuse orbs with additive blending.
* V2: 接入 themeStore,浅色态用 pastel orb + 白色 vignette/gradient,深色保持原品牌色。
*/
interface Orb {
cx: number; // base center X ratio (0-1)
cy: number; // base center Y ratio (0-1)
color: [number, number, number];
alpha: number;
phase: number;
freqX: number;
freqY: number;
ampX: number;
ampY: number;
radius: number;
breathFreq: number;
breathAmp: number;
}
// Dark theme orbs — vivid 品牌色(青/紫/蓝)
const DARK_ORBS: Orb[] = [
{ cx: 0.25, cy: 0.30, color: [126, 220, 200], alpha: 0.28,
phase: 0, freqX: 0.00012, freqY: 0.00010, ampX: 0.12, ampY: 0.10,
radius: 0.50, breathFreq: 0.0004, breathAmp: 0.06 },
{ cx: 0.72, cy: 0.65, color: [108, 99, 255], alpha: 0.22,
phase: 1.8, freqX: 0.00010, freqY: 0.00014, ampX: 0.14, ampY: 0.12,
radius: 0.45, breathFreq: 0.0003, breathAmp: 0.08 },
{ cx: 0.58, cy: 0.38, color: [59, 130, 246], alpha: 0.18,
phase: 3.2, freqX: 0.00015, freqY: 0.00008, ampX: 0.10, ampY: 0.15,
radius: 0.38, breathFreq: 0.0005, breathAmp: 0.07 },
{ cx: 0.20, cy: 0.70, color: [167, 139, 250], alpha: 0.15,
phase: 4.5, freqX: 0.00008, freqY: 0.00012, ampX: 0.16, ampY: 0.08,
radius: 0.40, breathFreq: 0.00035, breathAmp: 0.10 },
{ cx: 0.78, cy: 0.25, color: [34, 211, 238], alpha: 0.20,
phase: 5.8, freqX: 0.00014, freqY: 0.00016, ampX: 0.08, ampY: 0.12,
radius: 0.35, breathFreq: 0.00045, breathAmp: 0.09 },
];
// Light theme orbs — pastel 浅色,alpha 减半左右,给玻璃面提供穿透色源
const LIGHT_ORBS: Orb[] = [
{ cx: 0.25, cy: 0.30, color: [180, 167, 255], alpha: 0.32, // pastel lavender
phase: 0, freqX: 0.00012, freqY: 0.00010, ampX: 0.12, ampY: 0.10,
radius: 0.55, breathFreq: 0.0004, breathAmp: 0.06 },
{ cx: 0.72, cy: 0.65, color: [167, 200, 255], alpha: 0.28, // pastel sky
phase: 1.8, freqX: 0.00010, freqY: 0.00014, ampX: 0.14, ampY: 0.12,
radius: 0.50, breathFreq: 0.0003, breathAmp: 0.08 },
{ cx: 0.58, cy: 0.38, color: [255, 180, 130, ], alpha: 0.18, // pastel peach
phase: 3.2, freqX: 0.00015, freqY: 0.00008, ampX: 0.10, ampY: 0.15,
radius: 0.42, breathFreq: 0.0005, breathAmp: 0.07 },
{ cx: 0.20, cy: 0.70, color: [220, 167, 255], alpha: 0.20, // pastel pink-violet
phase: 4.5, freqX: 0.00008, freqY: 0.00012, ampX: 0.16, ampY: 0.08,
radius: 0.45, breathFreq: 0.00035, breathAmp: 0.10 },
{ cx: 0.78, cy: 0.25, color: [180, 220, 255], alpha: 0.22, // pastel blue
phase: 5.8, freqX: 0.00014, freqY: 0.00016, ampX: 0.08, ampY: 0.12,
radius: 0.40, breathFreq: 0.00045, breathAmp: 0.09 },
];
const MOUSE_RADIUS = 400;
const MOUSE_PUSH = 28;
export function AuroraCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const grainRef = useRef<HTMLCanvasElement>(null);
const mouseRef = useRef({ x: -9999, y: -9999, active: false });
const theme = useThemeStore((s) => s.theme);
const isLight = theme === 'light';
const handleMouseMove = useCallback((e: MouseEvent) => {
mouseRef.current.x = e.clientX;
mouseRef.current.y = e.clientY;
mouseRef.current.active = true;
}, []);
const handleMouseLeave = useCallback(() => {
mouseRef.current.active = false;
}, []);
// ── Aurora animation ──
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let w = window.innerWidth;
let h = window.innerHeight;
function resize() {
w = window.innerWidth;
h = window.innerHeight;
const dpr = w < 768 ? 0.5 : Math.min(window.devicePixelRatio, 1.5);
canvas!.width = w * dpr;
canvas!.height = h * dpr;
canvas!.style.width = w + 'px';
canvas!.style.height = h + 'px';
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
}
resize();
let animId: number;
const t0 = performance.now();
let smoothMx = -9999;
let smoothMy = -9999;
const orbs = isLight ? LIGHT_ORBS : DARK_ORBS;
function draw(now: number) {
const t = now - t0;
ctx!.clearRect(0, 0, w, h);
// 浅色用 source-over 让 pastel 互融时不会过曝;深色继续用 lighter 加合
ctx!.globalCompositeOperation = isLight ? 'source-over' : 'lighter';
const mouse = mouseRef.current;
if (mouse.active) {
smoothMx += (mouse.x - smoothMx) * 0.035;
smoothMy += (mouse.y - smoothMy) * 0.035;
} else {
smoothMx += (-9999 - smoothMx) * 0.01;
smoothMy += (-9999 - smoothMy) * 0.01;
}
for (const orb of orbs) {
let x = w * (orb.cx + Math.sin(t * orb.freqX + orb.phase) * orb.ampX);
let y = h * (orb.cy + Math.cos(t * orb.freqY + orb.phase * 0.7) * orb.ampY);
const dx = x - smoothMx;
const dy = y - smoothMy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < MOUSE_RADIUS && dist > 1) {
const strength = (1 - dist / MOUSE_RADIUS) * MOUSE_PUSH;
x += (dx / dist) * strength;
y += (dy / dist) * strength;
}
const breathT = Math.sin(t * orb.breathFreq + orb.phase * 1.3);
const r = Math.max(w, h) * orb.radius * (1 + breathT * orb.breathAmp);
const a = orb.alpha * (1 + breathT * 0.15);
const [cr, cg, cb] = orb.color;
const grad = ctx!.createRadialGradient(x, y, 0, x, y, r);
grad.addColorStop(0, `rgba(${cr},${cg},${cb},${(a).toFixed(3)})`);
grad.addColorStop(0.25, `rgba(${cr},${cg},${cb},${(a * 0.5).toFixed(3)})`);
grad.addColorStop(0.5, `rgba(${cr},${cg},${cb},${(a * 0.15).toFixed(3)})`);
grad.addColorStop(0.8, `rgba(${cr},${cg},${cb},${(a * 0.03).toFixed(3)})`);
grad.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
ctx!.fillStyle = grad;
ctx!.beginPath();
ctx!.arc(x, y, r, 0, Math.PI * 2);
ctx!.fill();
}
ctx!.globalCompositeOperation = 'source-over';
animId = requestAnimationFrame(draw);
}
animId = requestAnimationFrame(draw);
window.addEventListener('resize', resize);
window.addEventListener('mousemove', handleMouseMove, { passive: true });
document.addEventListener('mouseleave', handleMouseLeave);
return () => {
cancelAnimationFrame(animId);
window.removeEventListener('resize', resize);
window.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseleave', handleMouseLeave);
};
}, [handleMouseMove, handleMouseLeave, isLight]);
// ── Film grain — 4 FPS low-noise ──
useEffect(() => {
const canvas = grainRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
canvas.width = 256;
canvas.height = 256;
let animId: number;
let lastFrame = 0;
function drawGrain(now: number) {
if (now - lastFrame > 250) {
lastFrame = now;
const img = ctx!.createImageData(256, 256);
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
const v = Math.random() * 255;
d[i] = d[i + 1] = d[i + 2] = v;
d[i + 3] = 255;
}
ctx!.putImageData(img, 0, 0);
}
animId = requestAnimationFrame(drawGrain);
}
animId = requestAnimationFrame(drawGrain);
return () => cancelAnimationFrame(animId);
}, []);
// 浅色态:vignette / gradient 反相 — 用白色压边,黑色压边在浅色上是错的
const vignetteColor = isLight ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.8)';
const fadeColor = isLight ? 'rgba(250,250,250,0.7)' : 'rgba(0,0,0,0.5)';
return (
<>
{/* Layer 1: Vignette — radial fading, 浅色下用白色 */}
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 1,
background: `radial-gradient(ellipse 65% 55% at 50% 50%, transparent 0%, ${vignetteColor} 100%)`,
pointerEvents: 'none',
}}
/>
{/* Layer 2: Film grain — 浅色下大幅减弱避免噪点过曝 */}
<canvas
ref={grainRef}
style={{
position: 'absolute',
inset: 0,
zIndex: 2,
width: '100%',
height: '100%',
opacity: isLight ? 0.015 : 0.035,
pointerEvents: 'none',
mixBlendMode: isLight ? 'multiply' : 'overlay',
}}
/>
{/* Layer 3: Aurora — blur 让 orb 融成有机晕染 */}
<canvas
ref={canvasRef}
style={{
position: 'absolute',
inset: 0,
zIndex: 3,
filter: 'blur(50px)',
pointerEvents: 'none',
}}
/>
{/* Layer 4: 顶/底渐变压角 */}
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 4,
pointerEvents: 'none',
background:
`linear-gradient(to bottom, ${fadeColor} 0%, transparent 18%, transparent 82%, ${fadeColor} 100%)`,
}}
/>
</>
);
}