diff --git a/docs/changelog.md b/docs/changelog.md index f670b28..5006a49 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,77 @@ --- +## 2026-03-16 — v0.9.1: 首页 + 播放器修复 + +**状态**: ✅ 已完成 | **验收**: ✅ 通过(本地验证) + +### 变更内容 +1. **首页音乐泄漏修复** — 登录跳转 `/app` 后 BGM 继续播放,添加组件卸载时 pause + reset +2. **自适应视频播放器比例修复** — `adaptive` 比例视频在详情弹窗中固定 16:9 容器,改为读取视频固有 `videoWidth/videoHeight` 自适应缩放 +3. **自适应比例中文化** — 视频详情弹窗、生成卡片标签、详情 tooltip 中 `adaptive` 改为显示「自适应」 +4. **登录弹窗提示语** — 登录按钮下方添加「目前仅限受邀创作者体验」提示 + +### 变更文件 +| 文件 | 改动 | +|------|------| +| `web/src/pages/LandingPage.tsx` | 添加 useEffect 清理:组件卸载时暂停音乐 | +| `web/src/components/VideoDetailModal.tsx` | adaptive 比例读取视频固有尺寸 + 显示中文「自适应」 | +| `web/src/components/GenerationCard.tsx` | 两处 aspectRatio 显示改为中文「自适应」 | +| `web/src/components/LoginModal.tsx` | 添加受邀创作者提示文字 | +| `web/src/components/LoginModal.module.css` | 新增 `.hint` 样式 | + +--- + +## 2026-03-16 — v0.9.0: 品牌首页(Landing Page) + +**状态**: ✅ 已完成 | **验收**: ✅ 通过(本地验证) + +### 变更内容 +1. **5 层极光首页** — 全新单屏 Landing Page,Canvas 实现有机流动极光背景(暗角→胶片颗粒→极光光球→渐变遮罩→内容层) +2. **品牌标识** — 标题 "AIRFLOW STUDIO" + 副标题 "AI VISUAL NARRATIVE",Space Grotesk 字体(300/400/500) +3. **双入口按钮** — Air Drama(品牌青色高亮)/ Air Spark(幽灵按钮),中文副标题移至按钮下方独立小字 +4. **登录弹窗** — 点击 Air Drama 原地弹出登录 Modal(磨砂玻璃背景 + backdrop-filter: blur),替代独立登录页跳转 +5. **Air Spark 全屏提示** — 毛玻璃遮罩 + 戏剧性大字 "别急,土豆🥔正在 coding 中",点击任意位置关闭 +6. **鼠标极光交互** — 鼠标靠近光球时轻推偏移(28px),lerp 缓动跟踪,离开后缓慢回归 +7. **极光呼吸感** — 5 个光球各自不同位置/大小/亮度/呼吸频率,避免均匀蓝块,保持明暗对比 +8. **彩蛋文字** — 底部 "Every frame was once just air." opacity 0.06,hover 2s 过渡到 0.25 +9. **音乐彩蛋** — 右下角 SVG 音波图标,点击播放/暂停 BGM,竖线从中心向两端扩展跳动(JS 随机高度 + CSS transition) +10. **路由重构** — `/` → LandingPage, `/login` → LandingPage + 自动弹登录框, `/app` → VideoGenerationPage +11. **Logo 替换** — 全站 5 处 Logo 替换为品牌 Logo(favicon、侧栏、管理后台、团队管理、登录弹窗) + +### 变更文件 +| 文件 | 改动 | +|------|------| +| `web/src/components/AuroraCanvas.tsx` | 新建:Canvas 极光 + 胶片颗粒 + 暗角 + 鼠标交互 | +| `web/src/components/LoginModal.tsx` | 新建:登录弹窗组件 | +| `web/src/components/LoginModal.module.css` | 新建:磨砂玻璃弹窗样式 | +| `web/src/pages/LandingPage.tsx` | 重写:5 层架构 + 双按钮 + Air Spark 遮罩 + 音乐彩蛋 | +| `web/src/pages/LandingPage.module.css` | 重写:全新首页样式 | +| `web/src/App.tsx` | 路由重构:`/login` → `` | +| `web/index.html` | 添加 Space Grotesk 字体 + favicon | +| `web/src/components/Sidebar.tsx` | Logo 替换 + 导航 `/` → `/app` | +| `web/src/pages/AdminLayout.tsx` | Logo 替换 + 返回链接 `/app` | +| `web/src/pages/TeamAdminLayout.tsx` | Logo 替换 + 返回链接 `/app` | +| `web/src/pages/ProfilePage.tsx` | 返回链接 `/` → `/app` | +| `web/src/components/ProtectedRoute.tsx` | 未认证重定向 `/login` | +| `web/public/favicon.png` | 新增:品牌 favicon | +| `web/public/bgm.mp3` | 新增:首页背景音乐 | +| `web/src/assets/logo_32.png` | 新增:小尺寸 Logo | +| `web/src/assets/logo_128.png` | 新增:中尺寸 Logo | +| `web/src/assets/logo_512.png` | 新增:大尺寸 Logo | + +### 触发原因 +- 系统之前只有登录页,缺少品牌展示首页 +- 需要对标 Runway/Pika 等 AI 视频平台的首页质感 +- 两个子产品(Air Drama / Air Spark)需要统一入口 + +### 备注 +- 旧 LoginPage.tsx 保留未删除,作为备用 +- 极光效果使用 Canvas + `globalCompositeOperation: 'lighter'` + CSS `filter: blur(50px)` +- 移动端自动降级 Canvas 分辨率(0.5x DPR) + +--- + ## 2026-03-16 — v0.8.5: 安全加固(CRITICAL + HIGH 修复) **状态**: ✅ 已完成 | **验收**: 待线上验证 diff --git a/web/index.html b/web/index.html index 9c7a3d0..3283a81 100644 --- a/web/index.html +++ b/web/index.html @@ -2,7 +2,10 @@ - + + + + AirDrama — AI 视频生成 diff --git a/web/public/bgm.mp3 b/web/public/bgm.mp3 new file mode 100644 index 0000000..9c3335a Binary files /dev/null and b/web/public/bgm.mp3 differ diff --git a/web/src/App.tsx b/web/src/App.tsx index 74f6471..5bc70eb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,7 +4,7 @@ import { AmbientBackground } from './components/AmbientBackground'; import { Toast } from './components/Toast'; import { VideoGenerationPage } from './components/VideoGenerationPage'; import { ProtectedRoute } from './components/ProtectedRoute'; -import { LoginPage } from './pages/LoginPage'; +import { LandingPage } from './pages/LandingPage'; import { AdminLayout } from './pages/AdminLayout'; import { DashboardPage } from './pages/DashboardPage'; @@ -34,9 +34,10 @@ export default function App() { - } /> + } /> + } /> diff --git a/web/src/components/AuroraCanvas.tsx b/web/src/components/AuroraCanvas.tsx new file mode 100644 index 0000000..26ddd00 --- /dev/null +++ b/web/src/components/AuroraCanvas.tsx @@ -0,0 +1,250 @@ +import { useRef, useEffect, useCallback } from 'react'; + +/** + * Aurora background — 5 large diffuse orbs with additive blending. + * Orbs are deliberately offset from center to avoid uniform "blue blob" look. + * Mouse gently pushes nearby orbs (10-20px, very subtle). + */ + +interface Orb { + cx: number; // base center X ratio (0-1) + cy: number; // base center Y ratio (0-1) + color: [number, number, number]; + alpha: number; // peak alpha — varies per orb for brightness contrast + phase: number; + freqX: number; + freqY: number; + ampX: number; + ampY: number; + radius: number; // ratio of max(w,h) + // breathing + breathFreq: number; + breathAmp: number; +} + +// Orbs deliberately NOT centered — some in corners, some offset, +// creating uneven light distribution with dark pockets. +const ORBS: Orb[] = [ + // Cyan — upper-left area, large and bright + { 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 }, + // Purple — lower-right, medium + { 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 }, + // Blue — center-right, smaller and dimmer (fills gap) + { 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 }, + // Light purple — bottom-left corner, dim accent + { 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 }, + // Bright cyan — upper-right, small bright accent + { 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 }, +]; + +// Mouse influence radius (px) and max push distance (px) +const MOUSE_RADIUS = 400; +const MOUSE_PUSH = 28; + +export function AuroraCanvas() { + const canvasRef = useRef(null); + const grainRef = useRef(null); + const mouseRef = useRef({ x: -9999, y: -9999, active: false }); + + 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(); + // Smoothed mouse position for gentle push + let smoothMx = -9999; + let smoothMy = -9999; + + function draw(now: number) { + const t = now - t0; + ctx!.clearRect(0, 0, w, h); + ctx!.globalCompositeOperation = 'lighter'; + + // Smooth mouse tracking (lerp toward actual position) + const mouse = mouseRef.current; + if (mouse.active) { + smoothMx += (mouse.x - smoothMx) * 0.035; + smoothMy += (mouse.y - smoothMy) * 0.035; + } else { + // Slowly drift smoothed mouse away (return to no-influence) + smoothMx += (-9999 - smoothMx) * 0.01; + smoothMy += (-9999 - smoothMy) * 0.01; + } + + for (const orb of ORBS) { + // Base position from slow sinusoidal movement + let x = w * (orb.cx + Math.sin(t * orb.freqX + orb.phase) * orb.ampX); + let y = h * (orb.cy + Math.cos(t * orb.freqY + orb.phase * 0.7) * orb.ampY); + + // Mouse push — gently offset orb away from cursor + const dx = x - smoothMx; + const 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; + } + + // Breathing: radius and alpha pulse slowly + 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]); + + // ── 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); + }, []); + + return ( + <> + {/* Layer 1: Vignette — radial darkening, heavy at edges */} +
+ + {/* Layer 2: Film grain */} + + + {/* Layer 3: Aurora — blur merges orbs into organic glow */} + + + {/* Layer 4: Top/bottom gradient mask */} +
+ + ); +} diff --git a/web/src/components/GenerationCard.tsx b/web/src/components/GenerationCard.tsx index 1c77364..c560109 100644 --- a/web/src/components/GenerationCard.tsx +++ b/web/src/components/GenerationCard.tsx @@ -227,7 +227,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) { {task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'} {task.duration}s - {task.aspectRatio} + {task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio}
- 视频比例{task.aspectRatio} + 视频比例{task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio}
时长{task.duration}s diff --git a/web/src/components/LoginModal.module.css b/web/src/components/LoginModal.module.css new file mode 100644 index 0000000..6f34eca --- /dev/null +++ b/web/src/components/LoginModal.module.css @@ -0,0 +1,168 @@ +.overlay { + position: fixed; + inset: 0; + z-index: 50; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + display: flex; + align-items: center; + justify-content: center; + animation: overlayIn 0.3s ease-out; +} + +@keyframes overlayIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.panel { + position: relative; + width: 100%; + max-width: 400px; + margin: 0 20px; + background: rgba(255, 255, 255, 0.06); + backdrop-filter: blur(24px) saturate(180%); + -webkit-backdrop-filter: blur(24px) saturate(180%); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; + padding: 36px 32px 32px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05) inset; + animation: panelIn 0.3s ease-out; +} + +@keyframes panelIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.closeBtn { + position: absolute; + top: 14px; + right: 14px; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: rgba(255, 255, 255, 0.4); + cursor: pointer; + border-radius: 6px; + transition: all 0.2s; +} + +.closeBtn:hover { + color: rgba(255, 255, 255, 0.8); + background: rgba(255, 255, 255, 0.06); +} + +.header { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-bottom: 28px; +} + +.headerLogo { + width: 28px; + height: 28px; +} + +.headerTitle { + font-family: 'Space Grotesk', sans-serif; + font-size: 18px; + font-weight: 400; + color: #f1f0ff; + letter-spacing: 0.05em; +} + +.form { + display: flex; + flex-direction: column; + gap: 18px; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.label { + font-size: 13px; + color: #8b8ea8; + font-weight: 500; +} + +.input { + height: 44px; + padding: 0 14px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + color: #f1f0ff; + font-size: 14px; + outline: none; + transition: border-color 0.2s; +} + +.input::placeholder { + color: #4c4f6b; +} + +.input:focus { + border-color: rgba(126, 220, 200, 0.5); +} + +.error { + color: #ff4d4f; + font-size: 13px; + text-align: center; + padding: 8px; + background: rgba(255, 77, 79, 0.08); + border-radius: 8px; +} + +.submitBtn { + height: 44px; + width: 55%; + align-self: center; + margin-top: 18px; + background: rgba(120, 220, 200, 0.08); + border: 1px solid rgba(120, 220, 200, 0.3); + color: #7edcc8; + border-radius: 10px; + font-family: 'Space Grotesk', sans-serif; + font-size: 15px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.submitBtn:hover { + background: rgba(120, 220, 200, 0.18); + box-shadow: 0 0 24px rgba(120, 220, 200, 0.12); +} + +.submitBtn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.hint { + font-size: 12px; + color: rgba(255, 255, 255, 0.25); + text-align: center; + margin: 0; +} diff --git a/web/src/components/LoginModal.tsx b/web/src/components/LoginModal.tsx new file mode 100644 index 0000000..e3708e2 --- /dev/null +++ b/web/src/components/LoginModal.tsx @@ -0,0 +1,89 @@ +import { useState, useCallback } from 'react'; +import { useAuthStore } from '../store/auth'; +import logoImg from '../assets/logo_32.png'; +import styles from './LoginModal.module.css'; + +interface Props { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +} + +export function LoginModal({ isOpen, onClose, onSuccess }: Props) { + const login = useAuthStore((s) => s.login); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (!username.trim()) { setError('请输入用户名或邮箱'); return; } + if (password.length < 6) { setError('密码至少6位'); return; } + + setLoading(true); + try { + await login(username, password); + onSuccess(); + } catch (err: any) { + const msg = err.response?.data?.message || err.response?.data?.error || '登录失败,请重试'; + setError(msg); + } finally { + setLoading(false); + } + }, [username, password, login, onSuccess]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> + + +
+ + Air Drama +
+ +
+
+ + setUsername(e.target.value)} + placeholder="请输入用户名或邮箱" + autoFocus + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="请输入密码" + /> +
+ + {error &&
{error}
} + + + +

目前仅限受邀创作者体验

+
+
+
+ ); +} diff --git a/web/src/components/ProtectedRoute.tsx b/web/src/components/ProtectedRoute.tsx index a42562e..79cfde6 100644 --- a/web/src/components/ProtectedRoute.tsx +++ b/web/src/components/ProtectedRoute.tsx @@ -34,11 +34,11 @@ export function ProtectedRoute({ children, requireAdmin, requireTeamAdmin, requi } if (requireAdmin && user?.role !== 'super_admin') { - return ; + return ; } if (requireTeamAdmin && user?.role !== 'team_admin') { - return ; + return ; } // requireTeamMember: must have a team (team_admin or member) diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 324b148..f3c25ae 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ import { useNavigate, useLocation } from 'react-router-dom'; import { useAuthStore } from '../store/auth'; +import logoImg from '../assets/logo_32.png'; import styles from './Sidebar.module.css'; export function Sidebar() { @@ -18,12 +19,8 @@ export function Sidebar() { return (
+ + {/* Easter egg */} +

Every frame was once just air.

+ + {/* Air Spark full-screen overlay */} + {showSpark && ( +
+
+

别急,土豆🥔正在 coding 中

+

灵感自燃功能即将上线

+
+
+ )} + + {/* Music easter egg — bottom right */} + + + {/* Login modal */} + setShowLogin(false)} + onSuccess={handleLoginSuccess} + /> +
+ ); +} diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index cc100bf..c9f8c11 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuthStore } from '../store/auth'; +import logoImg from '../assets/logo_128.png'; import styles from './AuthPage.module.css'; export function LoginPage() { @@ -21,7 +22,7 @@ export function LoginPage() { setLoading(true); try { await login(username, password); - navigate('/', { replace: true }); + navigate('/app', { replace: true }); } catch (err: any) { const msg = err.response?.data?.message || err.response?.data?.error || '登录失败,请重试'; setError(msg); @@ -33,6 +34,7 @@ export function LoginPage() { return (
+ AirDrama

AirDrama

AI 视频生成平台

diff --git a/web/src/pages/ProfilePage.tsx b/web/src/pages/ProfilePage.tsx index 51e69a4..a8e11e0 100644 --- a/web/src/pages/ProfilePage.tsx +++ b/web/src/pages/ProfilePage.tsx @@ -108,7 +108,7 @@ export function ProfilePage() { return (
-