feat: v0.9.0~v0.9.1 — 5层极光首页 + 登录弹窗 + 播放器修复
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 2m0s
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 2m0s
- 全新 Landing Page:Canvas 极光动画(5色光球 + additive blending + blur 融合) - 暗角/胶片颗粒/渐变遮罩 4 层视觉架构 - 鼠标推动光球交互(lerp 缓动)+ 呼吸效果 - 登录弹窗(磨砂玻璃 backdrop-filter blur)替代独立登录页 - Air Spark 全屏毛玻璃弹窗 + 音乐彩蛋(SVG 音波 + BGM) - 品牌名 AIRFLOW STUDIO / AI VISUAL NARRATIVE + Space Grotesk 字体 - 路由重构:/ → LandingPage, /login → 自动弹登录框 - 自适应视频播放器比例修复(读取 videoWidth/videoHeight) - adaptive 英文显示改为中文「自适应」 - 首页音乐泄漏修复(组件卸载时 pause+reset) - 登录弹窗添加「目前仅限受邀创作者体验」提示 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6053c9b987
commit
e5273540e9
@ -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` → `<LandingPage autoLogin />` |
|
||||
| `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 修复)
|
||||
|
||||
**状态**: ✅ 已完成 | **验收**: 待线上验证
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500&display=swap" rel="stylesheet" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AirDrama — AI 视频生成</title>
|
||||
</head>
|
||||
|
||||
BIN
web/public/bgm.mp3
Normal file
BIN
web/public/bgm.mp3
Normal file
Binary file not shown.
@ -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() {
|
||||
<AmbientBackground />
|
||||
<Toast />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/login" element={<LandingPage autoLogin />} />
|
||||
<Route
|
||||
path="/"
|
||||
path="/app"
|
||||
element={
|
||||
<ProtectedRoute requireTeamMember>
|
||||
<VideoGenerationPage />
|
||||
|
||||
250
web/src/components/AuroraCanvas.tsx
Normal file
250
web/src/components/AuroraCanvas.tsx
Normal file
@ -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<HTMLCanvasElement>(null);
|
||||
const grainRef = useRef<HTMLCanvasElement>(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 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
background: 'radial-gradient(ellipse 65% 55% at 50% 50%, transparent 0%, rgba(0,0,0,0.8) 100%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Layer 2: Film grain */}
|
||||
<canvas
|
||||
ref={grainRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 2,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
opacity: 0.035,
|
||||
pointerEvents: 'none',
|
||||
mixBlendMode: 'overlay',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Layer 3: Aurora — blur merges orbs into organic glow */}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 3,
|
||||
filter: 'blur(50px)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Layer 4: Top/bottom gradient mask */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 4,
|
||||
pointerEvents: 'none',
|
||||
background:
|
||||
'linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 18%, transparent 82%, rgba(0,0,0,0.5) 100%)',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -227,7 +227,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
||||
{task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'}
|
||||
</span>
|
||||
<span className={styles.label}>{task.duration}s</span>
|
||||
<span className={styles.label}>{task.aspectRatio}</span>
|
||||
<span className={styles.label}>{task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio}</span>
|
||||
<span
|
||||
ref={detailLinkRef}
|
||||
className={styles.detailLink}
|
||||
@ -248,7 +248,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
||||
{detailHover && (
|
||||
<div className={styles.detailTooltip} style={{ top: detailPos.top, right: detailPos.right }}>
|
||||
<div className={styles.detailRow}>
|
||||
<span>视频比例</span><span>{task.aspectRatio}</span>
|
||||
<span>视频比例</span><span>{task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio}</span>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span>时长</span><span>{task.duration}s</span>
|
||||
|
||||
168
web/src/components/LoginModal.module.css
Normal file
168
web/src/components/LoginModal.module.css
Normal file
@ -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;
|
||||
}
|
||||
89
web/src/components/LoginModal.tsx
Normal file
89
web/src/components/LoginModal.tsx
Normal file
@ -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 (
|
||||
<div className={styles.overlay} onClick={onClose}>
|
||||
<div className={styles.panel} onClick={(e) => e.stopPropagation()}>
|
||||
<button className={styles.closeBtn} onClick={onClose} aria-label="关闭">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className={styles.header}>
|
||||
<img src={logoImg} alt="" className={styles.headerLogo} />
|
||||
<span className={styles.headerTitle}>Air Drama</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>用户名 / 邮箱</label>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入用户名或邮箱"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>密码</label>
|
||||
<input
|
||||
type="password"
|
||||
className={styles.input}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
<button type="submit" className={styles.submitBtn} disabled={loading}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
|
||||
<p className={styles.hint}>目前仅限受邀创作者体验</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -34,11 +34,11 @@ export function ProtectedRoute({ children, requireAdmin, requireTeamAdmin, requi
|
||||
}
|
||||
|
||||
if (requireAdmin && user?.role !== 'super_admin') {
|
||||
return <Navigate to="/" replace />;
|
||||
return <Navigate to="/app" replace />;
|
||||
}
|
||||
|
||||
if (requireTeamAdmin && user?.role !== 'team_admin') {
|
||||
return <Navigate to="/" replace />;
|
||||
return <Navigate to="/app" replace />;
|
||||
}
|
||||
|
||||
// requireTeamMember: must have a team (team_admin or member)
|
||||
|
||||
@ -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 (
|
||||
<aside className={styles.sidebar}>
|
||||
{/* Logo */}
|
||||
<div className={styles.logo} onClick={() => navigate(role === 'super_admin' ? '/admin/dashboard' : '/')}>
|
||||
<svg width="32" height="32" viewBox="0 0 28 28" fill="none">
|
||||
<path d="M4 8L14 2L24 8V20L14 26L4 20V8Z" fill="#6c63ff" opacity="0.9" />
|
||||
<path d="M14 2L24 8L14 14L4 8L14 2Z" fill="#8b83ff" />
|
||||
<path d="M10 10L18 6" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" opacity="0.6" />
|
||||
</svg>
|
||||
<div className={styles.logo} onClick={() => navigate(role === 'super_admin' ? '/admin/dashboard' : '/app')}>
|
||||
<img src={logoImg} alt="AirDrama" width="32" height="32" />
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
@ -32,8 +29,8 @@ export function Sidebar() {
|
||||
{role !== 'super_admin' && (
|
||||
<>
|
||||
<div
|
||||
className={`${styles.navItem} ${isActive('/') ? styles.active : ''}`}
|
||||
onClick={() => navigate('/')}
|
||||
className={`${styles.navItem} ${isActive('/app') ? styles.active : ''}`}
|
||||
onClick={() => navigate('/app')}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
|
||||
@ -31,15 +31,19 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
||||
const [showMoreMenu, setShowMoreMenu] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [fitSize, setFitSize] = useState<{ w: number; h: number } | null>(null);
|
||||
const [intrinsicRatio, setIntrinsicRatio] = useState<number | null>(null);
|
||||
const moreMenuRef = useRef<HTMLDivElement>(null);
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Parse aspect ratio from task
|
||||
// Parse aspect ratio from task; for 'adaptive', use video's intrinsic ratio
|
||||
const arNum = useMemo(() => {
|
||||
const ar = task?.aspectRatio || '16:9';
|
||||
if (ar === 'adaptive') {
|
||||
return intrinsicRatio || 16 / 9;
|
||||
}
|
||||
const parts = ar.split(':').map(Number);
|
||||
return (parts[0] && parts[1]) ? parts[0] / parts[1] : 16 / 9;
|
||||
}, [task?.aspectRatio]);
|
||||
}, [task?.aspectRatio, intrinsicRatio]);
|
||||
|
||||
// Compute container size to fit aspect ratio within videoArea
|
||||
useEffect(() => {
|
||||
@ -94,6 +98,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setShowMoreMenu(false);
|
||||
setIntrinsicRatio(null);
|
||||
}, [task?.id]);
|
||||
|
||||
// Track fullscreen changes
|
||||
@ -128,7 +133,12 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
const v = videoRef.current;
|
||||
if (v) setDuration(v.duration);
|
||||
if (v) {
|
||||
setDuration(v.duration);
|
||||
if (v.videoWidth && v.videoHeight) {
|
||||
setIntrinsicRatio(v.videoWidth / v.videoHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
@ -467,7 +477,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
||||
<span className={styles.infoBarDot} />
|
||||
<span>{task.duration}s</span>
|
||||
<span className={styles.infoBarDot} />
|
||||
<span>{task.aspectRatio}</span>
|
||||
<span>{task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.cardActions}>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { useState } from 'react';
|
||||
import logoImg from '../assets/logo_32.png';
|
||||
import styles from './AdminLayout.module.css';
|
||||
|
||||
const navItems = [
|
||||
@ -28,9 +29,7 @@ export function AdminLayout() {
|
||||
<aside className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}>
|
||||
<div className={styles.sidebarHeader}>
|
||||
<div className={styles.logo}>
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="var(--color-primary)">
|
||||
<path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"/>
|
||||
</svg>
|
||||
<img src={logoImg} alt="AirDrama" width="24" height="24" />
|
||||
{!collapsed && <span className={styles.logoText}>AirDrama Admin</span>}
|
||||
</div>
|
||||
<button className={styles.collapseBtn} onClick={() => setCollapsed(!collapsed)}>
|
||||
@ -45,7 +44,7 @@ export function AdminLayout() {
|
||||
</div>
|
||||
|
||||
<nav className={styles.nav}>
|
||||
<button className={styles.navItem} onClick={() => navigate('/')} style={{ border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left', width: '100%' }}>
|
||||
<button className={styles.navItem} onClick={() => navigate('/app')} style={{ border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left', width: '100%' }}>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
|
||||
</svg>
|
||||
|
||||
319
web/src/pages/LandingPage.module.css
Normal file
319
web/src/pages/LandingPage.module.css
Normal file
@ -0,0 +1,319 @@
|
||||
.page {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* ── Layer 5: Content ── */
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
animation: fadeUp 1.2s ease-out both;
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 28px;
|
||||
filter: drop-shadow(0 0 40px rgba(126, 220, 200, 0.25));
|
||||
animation: fadeUp 1.2s ease-out 0.1s both;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 48px;
|
||||
font-weight: 300;
|
||||
color: #f1f0ff;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.1;
|
||||
animation: fadeUp 1.2s ease-out 0.2s both;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
letter-spacing: 0.15em;
|
||||
margin-bottom: 48px;
|
||||
animation: fadeUp 1.2s ease-out 0.3s both;
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
animation: fadeUp 1.2s ease-out 0.4s both;
|
||||
}
|
||||
|
||||
.btnGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btnPrimary,
|
||||
.btnGhost {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px 32px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.btnPrimary {
|
||||
background: rgba(120, 220, 200, 0.12);
|
||||
border: 1px solid rgba(120, 220, 200, 0.3);
|
||||
}
|
||||
|
||||
.btnPrimary .btnName {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #7edcc8;
|
||||
}
|
||||
|
||||
.btnPrimary:hover {
|
||||
background: rgba(120, 220, 200, 0.22);
|
||||
box-shadow: 0 0 30px rgba(120, 220, 200, 0.15);
|
||||
}
|
||||
|
||||
.btnGhost {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.btnGhost .btnName {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.btnGhost:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.btnGhost:hover .btnName {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* Sub-text below buttons */
|
||||
.btnSub {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 300;
|
||||
color: rgba(120, 220, 200, 0.5);
|
||||
}
|
||||
|
||||
.btnSubGhost {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 300;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
/* ── Easter egg ── */
|
||||
.easter {
|
||||
position: absolute;
|
||||
bottom: 36px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
color: rgba(255, 255, 255, 0.06);
|
||||
letter-spacing: 0.05em;
|
||||
cursor: default;
|
||||
transition: color 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);
|
||||
}
|
||||
|
||||
/* ── Air Spark full-screen overlay ── */
|
||||
.sparkOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(30px);
|
||||
-webkit-backdrop-filter: blur(30px);
|
||||
animation: sparkBgIn 0.5s ease-out both;
|
||||
}
|
||||
|
||||
@keyframes sparkBgIn {
|
||||
from {
|
||||
backdrop-filter: blur(0px);
|
||||
-webkit-backdrop-filter: blur(0px);
|
||||
background: rgba(0, 0, 0, 0);
|
||||
}
|
||||
to {
|
||||
backdrop-filter: blur(30px);
|
||||
-webkit-backdrop-filter: blur(30px);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.sparkContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
animation: sparkTextIn 0.4s ease-out 0.1s both;
|
||||
}
|
||||
|
||||
@keyframes sparkTextIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.sparkTitle {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: clamp(40px, 5vw, 64px);
|
||||
font-weight: 300;
|
||||
color: #ffffff;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sparkEmoji {
|
||||
font-size: 0.6em;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.sparkSub {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* ── Music button — bottom right ── */
|
||||
.musicBtn {
|
||||
position: absolute;
|
||||
bottom: 25px;
|
||||
right: 32px;
|
||||
z-index: 10;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
animation: fadeUp 1.2s ease-out 0.8s both;
|
||||
}
|
||||
|
||||
.musicBtn:hover {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 768px) {
|
||||
.logo {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 12px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.btnPrimary,
|
||||
.btnGhost {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.easter {
|
||||
font-size: 11px;
|
||||
bottom: 24px;
|
||||
}
|
||||
|
||||
.musicBtn {
|
||||
bottom: 24px;
|
||||
right: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.content,
|
||||
.logo,
|
||||
.title,
|
||||
.tagline,
|
||||
.actions,
|
||||
.easter,
|
||||
.sparkOverlay,
|
||||
.sparkContent,
|
||||
.musicBtn {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
}
|
||||
166
web/src/pages/LandingPage.tsx
Normal file
166
web/src/pages/LandingPage.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { AuroraCanvas } from '../components/AuroraCanvas';
|
||||
import { LoginModal } from '../components/LoginModal';
|
||||
import logoImg from '../assets/logo_512.png';
|
||||
import styles from './LandingPage.module.css';
|
||||
|
||||
interface Props {
|
||||
autoLogin?: boolean;
|
||||
}
|
||||
|
||||
export function LandingPage({ autoLogin }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const [showSpark, setShowSpark] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [barHeights, setBarHeights] = useState([0.4, 0.4, 0.4, 0.4]);
|
||||
|
||||
// Auto-open login modal when rendered via /login route
|
||||
useEffect(() => {
|
||||
if (autoLogin && !isAuthenticated) {
|
||||
setShowLogin(true);
|
||||
}
|
||||
}, [autoLogin, isAuthenticated]);
|
||||
|
||||
const handleAirDrama = () => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/app');
|
||||
} else {
|
||||
setShowLogin(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAirSpark = () => {
|
||||
setShowSpark(true);
|
||||
};
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
setShowLogin(false);
|
||||
navigate('/app', { replace: true });
|
||||
};
|
||||
|
||||
// Close spark overlay on click
|
||||
const closeSpark = useCallback(() => setShowSpark(false), []);
|
||||
|
||||
// Animate bar heights when playing
|
||||
useEffect(() => {
|
||||
if (!playing) {
|
||||
setBarHeights([0.4, 0.4, 0.4, 0.4]);
|
||||
return;
|
||||
}
|
||||
const id = setInterval(() => {
|
||||
setBarHeights([
|
||||
0.3 + Math.random() * 0.7,
|
||||
0.3 + Math.random() * 0.7,
|
||||
0.3 + Math.random() * 0.7,
|
||||
0.3 + Math.random() * 0.7,
|
||||
]);
|
||||
}, 150);
|
||||
return () => clearInterval(id);
|
||||
}, [playing]);
|
||||
|
||||
// Stop music on unmount (e.g. navigating to /app after login)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Music toggle
|
||||
const toggleMusic = useCallback(() => {
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio('/bgm.mp3');
|
||||
audioRef.current.loop = true;
|
||||
}
|
||||
if (playing) {
|
||||
audioRef.current.pause();
|
||||
} else {
|
||||
audioRef.current.play();
|
||||
}
|
||||
setPlaying((p) => !p);
|
||||
}, [playing]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
{/* Layer 1-4: Aurora background */}
|
||||
<AuroraCanvas />
|
||||
|
||||
{/* Layer 5: Content */}
|
||||
<div className={styles.content}>
|
||||
<img src={logoImg} alt="AirDrama" className={styles.logo} />
|
||||
|
||||
<h1 className={styles.title}>AIRFLOW STUDIO</h1>
|
||||
<p className={styles.tagline}>AI VISUAL NARRATIVE</p>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<div className={styles.btnGroup}>
|
||||
<button className={styles.btnPrimary} onClick={handleAirDrama}>
|
||||
<span className={styles.btnName}>Air Drama</span>
|
||||
</button>
|
||||
<span className={styles.btnSub}>逐帧造梦</span>
|
||||
</div>
|
||||
<div className={styles.btnGroup}>
|
||||
<button className={styles.btnGhost} onClick={handleAirSpark}>
|
||||
<span className={styles.btnName}>Air Spark</span>
|
||||
</button>
|
||||
<span className={styles.btnSubGhost}>灵感自燃</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Easter egg */}
|
||||
<p className={styles.easter}>Every frame was once just air.</p>
|
||||
|
||||
{/* Air Spark full-screen overlay */}
|
||||
{showSpark && (
|
||||
<div className={styles.sparkOverlay} onClick={closeSpark}>
|
||||
<div className={styles.sparkContent}>
|
||||
<p className={styles.sparkTitle}>别急,土豆<span className={styles.sparkEmoji}>🥔</span>正在 coding 中</p>
|
||||
<p className={styles.sparkSub}>灵感自燃功能即将上线</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Music easter egg — bottom right */}
|
||||
<button
|
||||
className={styles.musicBtn}
|
||||
onClick={toggleMusic}
|
||||
aria-label={playing ? '暂停音乐' : '播放音乐'}
|
||||
>
|
||||
<svg width="20" height="18" viewBox="0 0 20 18" fill="none">
|
||||
{[0, 1, 2, 3].map((i) => {
|
||||
const maxH = 16;
|
||||
const h = maxH * barHeights[i];
|
||||
const y = 9 - h / 2; // center at y=9
|
||||
return (
|
||||
<rect
|
||||
key={i}
|
||||
x={1 + i * 5}
|
||||
y={y}
|
||||
width="3"
|
||||
height={h}
|
||||
rx="1.5"
|
||||
fill="currentColor"
|
||||
style={{ transition: 'y 0.15s ease, height 0.15s ease' }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Login modal */}
|
||||
<LoginModal
|
||||
isOpen={showLogin}
|
||||
onClose={() => setShowLogin(false)}
|
||||
onSuccess={handleLoginSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.card}>
|
||||
<img src={logoImg} alt="AirDrama" width="64" height="64" style={{ marginBottom: '12px' }} />
|
||||
<h1 className={styles.title}>AirDrama</h1>
|
||||
<p className={styles.subtitle}>AI 视频生成平台</p>
|
||||
|
||||
|
||||
@ -108,7 +108,7 @@ export function ProfilePage() {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<button className={styles.backBtn} onClick={() => navigate('/')}>
|
||||
<button className={styles.backBtn} onClick={() => navigate('/app')}>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/>
|
||||
</svg>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { useState } from 'react';
|
||||
import logoImg from '../assets/logo_32.png';
|
||||
import styles from './AdminLayout.module.css';
|
||||
|
||||
const navItems = [
|
||||
@ -24,9 +25,7 @@ export function TeamAdminLayout() {
|
||||
<aside className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}>
|
||||
<div className={styles.sidebarHeader}>
|
||||
<div className={styles.logo}>
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="var(--color-primary)">
|
||||
<path d="M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z"/>
|
||||
</svg>
|
||||
<img src={logoImg} alt="AirDrama" width="24" height="24" />
|
||||
{!collapsed && <span className={styles.logoText}>团队管理</span>}
|
||||
</div>
|
||||
<button className={styles.collapseBtn} onClick={() => setCollapsed(!collapsed)}>
|
||||
@ -41,7 +40,7 @@ export function TeamAdminLayout() {
|
||||
</div>
|
||||
|
||||
<nav className={styles.nav}>
|
||||
<button className={styles.navItem} onClick={() => navigate('/')} style={{ border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left', width: '100%' }}>
|
||||
<button className={styles.navItem} onClick={() => navigate('/app')} style={{ border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left', width: '100%' }}>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
|
||||
</svg>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user