feat: 前端UI重构 — Air Spark设计系统对标

- 全局样式对标Air Spark设计系统(背景、glass card、配色、圆角)
- 视频详情弹窗(VideoDetailModal)全屏预览+信息面板
- GenerationCard重构:fixed定位tooltip、9:16视频适配、blob下载
- 个人中心:总额度/今日/本月三卡片布局
- Dashboard图表配色统一为#6c63ff主色调
- Sidebar、InputBar、Toolbar等组件样式优化
- 新增AmbientBackground、AssetsPage组件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-15 18:48:07 +08:00
parent c1f29cbf85
commit f8358a28c6
45 changed files with 3022 additions and 664 deletions

View File

@ -27,8 +27,10 @@ logger = logging.getLogger(__name__)
# File validation constants # File validation constants
ALLOWED_IMAGE_EXTS = {'jpeg', 'jpg', 'png', 'webp', 'bmp', 'tiff', 'gif'} ALLOWED_IMAGE_EXTS = {'jpeg', 'jpg', 'png', 'webp', 'bmp', 'tiff', 'gif'}
ALLOWED_VIDEO_EXTS = {'mp4', 'mov'} ALLOWED_VIDEO_EXTS = {'mp4', 'mov'}
ALLOWED_AUDIO_EXTS = {'mp3', 'wav'}
MAX_IMAGE_SIZE = 30 * 1024 * 1024 # 30MB MAX_IMAGE_SIZE = 30 * 1024 * 1024 # 30MB
MAX_VIDEO_SIZE = 50 * 1024 * 1024 # 50MB MAX_VIDEO_SIZE = 50 * 1024 * 1024 # 50MB
MAX_AUDIO_SIZE = 15 * 1024 * 1024 # 15MB
# Columns added in migration 0003; may not exist in production DB yet. # Columns added in migration 0003; may not exist in production DB yet.
_M0003_COLS = ('ark_task_id', 'result_url', 'error_message', 'reference_urls') _M0003_COLS = ('ark_task_id', 'result_url', 'error_message', 'reference_urls')
@ -82,6 +84,9 @@ def upload_media_view(request):
elif ext in ALLOWED_VIDEO_EXTS: elif ext in ALLOWED_VIDEO_EXTS:
media_type = 'video' media_type = 'video'
max_size = MAX_VIDEO_SIZE max_size = MAX_VIDEO_SIZE
elif ext in ALLOWED_AUDIO_EXTS:
media_type = 'audio'
max_size = MAX_AUDIO_SIZE
else: else:
return Response( return Response(
{'error': f'不支持的文件格式: {ext}'}, {'error': f'不支持的文件格式: {ext}'},
@ -276,10 +281,11 @@ def video_tasks_list_view(request):
return Response({'results': results}) return Response({'results': results})
@api_view(['GET']) @api_view(['GET', 'DELETE'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def video_task_detail_view(request, task_id): def video_task_detail_view(request, task_id):
"""GET /api/v1/video/tasks/<task_id> — Get task status, poll Seedance if active.""" """GET /api/v1/video/tasks/<task_id> — Get task status, poll Seedance if active.
DELETE /api/v1/video/tasks/<task_id> Delete task record."""
try: try:
record = _eval_qs( record = _eval_qs(
GenerationRecord.objects.filter(user=request.user), GenerationRecord.objects.filter(user=request.user),
@ -288,6 +294,10 @@ def video_task_detail_view(request, task_id):
except GenerationRecord.DoesNotExist: except GenerationRecord.DoesNotExist:
return Response({'error': '任务不存在'}, status=status.HTTP_404_NOT_FOUND) return Response({'error': '任务不存在'}, status=status.HTTP_404_NOT_FOUND)
if request.method == 'DELETE':
record.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
# If task is still active, poll Seedance API for latest status # If task is still active, poll Seedance API for latest status
ark_task_id = record.__dict__.get('ark_task_id', '') ark_task_id = record.__dict__.get('ark_task_id', '')
if record.status in ('queued', 'processing') and ark_task_id: if record.status in ('queued', 'processing') and ark_task_id:

View File

@ -1,4 +1,4 @@
"""ASGI config for Jimeng Clone backend.""" """ASGI config for AirDrama backend."""
import os import os
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application

View File

@ -1,4 +1,4 @@
"""Django settings for Jimeng Clone backend.""" """Django settings for AirDrama backend."""
import os import os
from pathlib import Path from pathlib import Path
@ -8,7 +8,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get( SECRET_KEY = os.environ.get(
'DJANGO_SECRET_KEY', 'DJANGO_SECRET_KEY',
'django-insecure-dev-key-change-in-production-jimeng-clone-2026' 'django-insecure-dev-key-change-in-production-airdrama-2026'
) )
DEBUG = os.environ.get('DJANGO_DEBUG', 'True').lower() in ('true', '1', 'yes') DEBUG = os.environ.get('DJANGO_DEBUG', 'True').lower() in ('true', '1', 'yes')

View File

@ -1,4 +1,4 @@
"""WSGI config for Jimeng Clone backend.""" """WSGI config for AirDrama backend."""
import os import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>即梦 — AI 视频生成</title> <title>AirDrama — AI 视频生成</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

24
web/package-lock.json generated
View File

@ -170,6 +170,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@ -531,6 +532,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=20.19.0"
}, },
@ -571,6 +573,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=20.19.0"
} }
@ -1560,8 +1563,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
@ -1653,6 +1655,7 @@
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
@ -1664,6 +1667,7 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
} }
@ -1839,7 +1843,6 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -1850,7 +1853,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@ -1950,6 +1952,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@ -2209,8 +2212,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/dom-helpers": { "node_modules/dom-helpers": {
"version": "5.2.1", "version": "5.2.1",
@ -2680,6 +2682,7 @@
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@acemir/cssom": "^0.9.31", "@acemir/cssom": "^0.9.31",
"@asamuzakjp/dom-selector": "^6.8.1", "@asamuzakjp/dom-selector": "^6.8.1",
@ -2775,7 +2778,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
@ -2929,6 +2931,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -3018,7 +3021,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0", "ansi-styles": "^5.0.0",
@ -3033,8 +3035,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
@ -3074,6 +3075,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@ -3098,6 +3100,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@ -3593,6 +3596,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,5 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AmbientBackground } from './components/AmbientBackground';
import { VideoGenerationPage } from './components/VideoGenerationPage'; import { VideoGenerationPage } from './components/VideoGenerationPage';
import { ProtectedRoute } from './components/ProtectedRoute'; import { ProtectedRoute } from './components/ProtectedRoute';
import { LoginPage } from './pages/LoginPage'; import { LoginPage } from './pages/LoginPage';
@ -10,6 +11,7 @@ import { UsersPage } from './pages/UsersPage';
import { RecordsPage } from './pages/RecordsPage'; import { RecordsPage } from './pages/RecordsPage';
import { SettingsPage } from './pages/SettingsPage'; import { SettingsPage } from './pages/SettingsPage';
import { ProfilePage } from './pages/ProfilePage'; import { ProfilePage } from './pages/ProfilePage';
import { AssetsPage } from './pages/AssetsPage';
import { useAuthStore } from './store/auth'; import { useAuthStore } from './store/auth';
export default function App() { export default function App() {
@ -21,6 +23,7 @@ export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<AmbientBackground />
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route <Route
@ -31,6 +34,14 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/assets"
element={
<ProtectedRoute>
<AssetsPage />
</ProtectedRoute>
}
/>
<Route <Route
path="/profile" path="/profile"
element={ element={

View File

@ -0,0 +1,48 @@
import { useEffect, useRef } from 'react';
export function AmbientBackground() {
const glowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = glowRef.current;
if (!el) return;
let rafId: number;
let targetX = 50;
let targetY = 50;
let currentX = 50;
let currentY = 50;
const handleMouseMove = (e: MouseEvent) => {
targetX = (e.clientX / window.innerWidth) * 100;
targetY = (e.clientY / window.innerHeight) * 100;
};
const animate = () => {
currentX += (targetX - currentX) * 0.08;
currentY += (targetY - currentY) * 0.08;
el.style.setProperty('--mouse-x', `${currentX}%`);
el.style.setProperty('--mouse-y', `${currentY}%`);
rafId = requestAnimationFrame(animate);
};
window.addEventListener('mousemove', handleMouseMove, { passive: true });
rafId = requestAnimationFrame(animate);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
cancelAnimationFrame(rafId);
};
}, []);
return (
<>
<div className="aurora-bg" aria-hidden="true">
<div className="aurora-blob-3" />
</div>
<div className="grid-pattern" aria-hidden="true" />
<div className="noise-overlay" aria-hidden="true" />
<div ref={glowRef} className="cursor-glow" aria-hidden="true" />
</>
);
}

View File

@ -11,6 +11,7 @@
border-radius: var(--radius-dropdown); border-radius: var(--radius-dropdown);
padding: 6px; padding: 6px;
z-index: 100; z-index: 100;
backdrop-filter: blur(20px) saturate(180%);
opacity: 0; opacity: 0;
transform: translateY(8px); transform: translateY(8px);
pointer-events: none; pointer-events: none;

View File

@ -1,11 +1,12 @@
.card { .card {
background: var(--color-bg-input-bar); background: transparent;
border: 1px solid var(--color-border-input-bar); border: none;
border-radius: 16px; border-radius: 0;
padding: 20px; padding: 20px 0;
max-width: 680px; max-width: 800px;
width: 100%; width: 100%;
animation: cardFadeIn 0.3s ease-out; animation: cardFadeIn 0.3s ease-out;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
} }
@keyframes cardFadeIn { @keyframes cardFadeIn {
@ -17,61 +18,30 @@
.header { .header {
display: flex; display: flex;
gap: 12px; gap: 12px;
margin-bottom: 16px; margin-bottom: 12px;
} }
.avatar { .refColumn {
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(0, 184, 230, 0.12);
color: var(--color-primary);
display: flex; display: flex;
align-items: center; gap: 4px;
justify-content: center;
flex-shrink: 0; flex-shrink: 0;
} }
.headerContent { .headerRight {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.prompt { /* Reference thumbnails row (legacy) */
font-size: 14px;
color: var(--color-text-primary);
line-height: 1.6;
margin-bottom: 4px;
word-break: break-word;
}
.meta {
font-size: 12px;
color: var(--color-text-secondary);
display: flex;
align-items: center;
gap: 6px;
}
.metaDot {
opacity: 0.4;
}
/* Content */
.content {
margin-bottom: 16px;
}
/* Reference thumbnails row */
.refRow { .refRow {
display: flex; display: flex;
gap: 6px; gap: 6px;
margin-bottom: 12px; margin-bottom: 10px;
} }
.refThumb { .refThumb {
width: 48px;
height: 48px; height: 48px;
aspect-ratio: 3 / 4;
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
background: #1a1a24; background: #1a1a24;
@ -79,6 +49,15 @@
border: 1px solid #2a2a38; border: 1px solid #2a2a38;
} }
.audioThumb {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
}
.refMedia { .refMedia {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -86,24 +65,191 @@
display: block; display: block;
} }
/* Prompt with tooltip */
.promptWrapper {
position: relative;
}
.promptLine {
font-size: 14px;
color: var(--color-text-primary);
line-height: 1.6;
word-break: break-word;
max-height: calc(1.6em * 2);
}
.promptTooltip {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
background: #1e1e2a;
border: 1px solid #2a2a38;
border-radius: 10px;
padding: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
animation: tooltipFadeIn 0.15s ease-out;
}
@keyframes tooltipFadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.promptTooltipText {
font-size: 13px;
color: var(--color-text-primary);
line-height: 1.6;
margin-bottom: 8px;
word-break: break-word;
}
.copyBtn {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 6px;
font-size: 12px;
color: var(--color-primary);
background: rgba(108, 99, 255, 0.1);
border: 1px solid rgba(108, 99, 255, 0.2);
cursor: pointer;
transition: background 0.15s;
font-family: inherit;
}
.copyBtn:hover {
background: rgba(108, 99, 255, 0.18);
}
/* Inline labels after prompt text */
.labelsInline {
display: inline;
margin-left: 6px;
white-space: nowrap;
}
.label {
display: inline-flex;
font-size: 12px;
color: var(--color-text-secondary);
padding: 1px 6px;
background: rgba(255, 255, 255, 0.06);
border-radius: 4px;
white-space: nowrap;
margin-left: 4px;
vertical-align: middle;
position: relative;
top: -1px;
}
/* Detail info link + tooltip */
.detailLink {
font-size: 12px;
color: var(--color-text-secondary);
cursor: default;
margin-left: 6px;
position: relative;
vertical-align: middle;
}
.detailTooltip {
position: fixed;
z-index: 1000;
background: rgba(13, 13, 26, 0.95);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.10);
border-radius: 8px;
padding: 12px 20px;
min-width: 260px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
animation: detailTooltipFadeIn 0.15s ease-out;
}
@keyframes detailTooltipFadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.detailRow {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 4px 0;
font-size: 13px;
white-space: nowrap;
}
.detailRow span:first-child {
color: var(--color-text-secondary);
}
.detailRow span:last-child {
color: var(--color-text-primary);
}
/* Content */
.content {
margin-bottom: 8px;
}
/* Result area */ /* Result area */
.resultArea { .resultArea {
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
background: #0e0e16; background: rgba(0, 0, 0, 0.3);
min-height: 200px; aspect-ratio: 16 / 9;
max-height: 320px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
position: relative;
} }
.resultMedia { .resultMedia {
width: 100%; width: 100%;
max-height: 400px; height: 100%;
object-fit: contain; object-fit: contain;
display: block; display: block;
} }
/* Video hover overlay with download */
.videoOverlay {
position: absolute;
inset: 0;
background: transparent;
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding: 12px;
animation: overlayFadeIn 0.15s ease-out;
}
@keyframes overlayFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.downloadBtn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
cursor: pointer;
transition: background 0.15s;
}
.downloadBtn:hover {
background: rgba(255, 255, 255, 0.25);
}
.resultPlaceholder { .resultPlaceholder {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -114,23 +260,46 @@
padding: 40px; padding: 40px;
} }
/* Generating state */ /* Generating state — shimmer background */
.shimmerBg {
position: absolute;
inset: 0;
background: linear-gradient(
110deg,
rgba(108, 99, 255, 0.03) 0%,
rgba(108, 99, 255, 0.08) 40%,
rgba(139, 92, 246, 0.12) 50%,
rgba(108, 99, 255, 0.08) 60%,
rgba(108, 99, 255, 0.03) 100%
);
background-size: 200% 100%;
animation: shimmer 2.5s ease-in-out infinite;
z-index: 0;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.generating { .generating {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 12px; gap: 10px;
padding: 40px; padding: 32px 40px;
width: 100%; width: 100%;
position: relative;
z-index: 1;
} }
.loadingSpinner { .loadingSpinner {
width: 36px; width: 32px;
height: 36px; height: 32px;
border: 3px solid #2a2a38; border: 2.5px solid rgba(108, 99, 255, 0.15);
border-top-color: var(--color-primary); border-top-color: var(--color-primary);
border-radius: 50%; border-radius: 50%;
animation: spin 0.8s linear infinite; animation: spin 1s linear infinite;
} }
@keyframes spin { @keyframes spin {
@ -140,30 +309,42 @@
.loadingText { .loadingText {
font-size: 13px; font-size: 13px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
letter-spacing: 0.5px;
} }
.progressBar { .progressBar {
width: 100%; width: 100%;
max-width: 300px; max-width: 200px;
height: 4px; height: 3px;
background: #2a2a38; background: rgba(255, 255, 255, 0.06);
border-radius: 2px; border-radius: 2px;
overflow: hidden; overflow: hidden;
} }
.progressFill { .progressFill {
height: 100%; height: 100%;
background: var(--color-primary); background: linear-gradient(90deg, var(--color-primary), #8b5cf6);
border-radius: 2px; border-radius: 2px;
transition: width 0.4s ease; transition: width 0.6s ease;
}
.progressText {
font-size: 11px;
color: var(--color-text-disabled);
}
/* Failed state — no video box, just text */
.errorText {
color: #e74c3c;
font-size: 13px;
line-height: 1.5;
padding: 8px 0;
} }
/* Action buttons */ /* Action buttons */
.actions { .actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
} }
.actionBtn { .actionBtn {
@ -191,3 +372,67 @@
border-color: rgba(255, 107, 107, 0.3); border-color: rgba(255, 107, 107, 0.3);
background: rgba(255, 107, 107, 0.08); background: rgba(255, 107, 107, 0.08);
} }
/* More menu */
.moreMenu {
position: relative;
margin-left: auto;
}
.moreBtn {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 8px;
color: var(--color-text-disabled);
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
cursor: pointer;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.moreBtn:hover {
color: var(--color-text-secondary);
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
}
.moreDropdown {
position: absolute;
bottom: calc(100% + 6px);
right: 0;
background: rgba(13, 13, 26, 0.95);
backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.10);
border-radius: 10px;
padding: 4px;
min-width: 100px;
z-index: 10;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
animation: dropdownFadeIn 0.12s ease-out;
}
@keyframes dropdownFadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.moreDropdown button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 14px;
background: none;
border: none;
color: #ff6b6b;
font-size: 13px;
cursor: pointer;
border-radius: 6px;
font-family: inherit;
text-align: left;
white-space: nowrap;
transition: background 0.12s;
}
.moreDropdown button:hover {
background: rgba(255, 107, 107, 0.10);
}

View File

@ -1,5 +1,7 @@
import { useRef, useState, useEffect, useCallback } from 'react';
import type { GenerationTask } from '../types'; import type { GenerationTask } from '../types';
import { useGenerationStore } from '../store/generation'; import { useGenerationStore } from '../store/generation';
import { showToast } from './Toast';
import styles from './GenerationCard.module.css'; import styles from './GenerationCard.module.css';
const EditIcon = () => ( const EditIcon = () => (
@ -16,13 +18,6 @@ const RefreshIcon = () => (
</svg> </svg>
); );
const TrashIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
const VideoIcon = () => ( const VideoIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="23 7 16 12 23 17 23 7" /> <polygon points="23 7 16 12 23 17 23 7" />
@ -30,45 +25,184 @@ const VideoIcon = () => (
</svg> </svg>
); );
const DownloadIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
);
interface Props { interface Props {
task: GenerationTask; task: GenerationTask;
onOpenDetail?: (task: GenerationTask) => void;
} }
export function GenerationCard({ task }: Props) { export function GenerationCard({ task, onOpenDetail }: Props) {
const removeTask = useGenerationStore((s) => s.removeTask); const removeTask = useGenerationStore((s) => s.removeTask);
const reEdit = useGenerationStore((s) => s.reEdit); const reEdit = useGenerationStore((s) => s.reEdit);
const regenerate = useGenerationStore((s) => s.regenerate); const regenerate = useGenerationStore((s) => s.regenerate);
const videoRef = useRef<HTMLVideoElement>(null);
const moreRef = useRef<HTMLDivElement>(null);
const promptLineRef = useRef<HTMLDivElement>(null);
const labelsRef = useRef<HTMLSpanElement>(null);
const [videoHover, setVideoHover] = useState(false);
const [promptHover, setPromptHover] = useState(false);
const [showMore, setShowMore] = useState(false);
const [truncatedPrompt, setTruncatedPrompt] = useState(task.prompt);
const [detailHover, setDetailHover] = useState(false);
const [detailPos, setDetailPos] = useState({ top: 0, right: 0 });
const detailLinkRef = useRef<HTMLSpanElement>(null);
// Close more menu on click outside
useEffect(() => {
if (!showMore) return;
const handler = (e: MouseEvent) => {
if (moreRef.current && !moreRef.current.contains(e.target as Node)) {
setShowMore(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showMore]);
// JS-level prompt truncation: ensure labels always visible at end of line 2
const computeTruncation = useCallback(() => {
const container = promptLineRef.current;
const labelsEl = labelsRef.current;
if (!container || !labelsEl) return;
const containerWidth = container.offsetWidth;
if (containerWidth === 0) return;
const style = getComputedStyle(container);
const font = `${style.fontSize} ${style.fontFamily}`;
// Measure labels width
const labelsWidth = labelsEl.offsetWidth + 8; // +8 for gap
// Two lines of available width, minus labels on line 2
const totalAvailable = containerWidth * 2 - labelsWidth;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
ctx.font = font;
const prompt = task.prompt || '';
let totalWidth = 0;
let needsTruncation = false;
// Check if prompt fits
const fullWidth = ctx.measureText(prompt).width;
if (fullWidth <= totalAvailable) {
setTruncatedPrompt(prompt);
return;
}
// Truncate character by character
let truncated = '';
const ellipsisWidth = ctx.measureText('…').width;
for (const char of prompt) {
const charWidth = ctx.measureText(char).width;
if (totalWidth + charWidth + ellipsisWidth > totalAvailable) {
needsTruncation = true;
break;
}
truncated += char;
totalWidth += charWidth;
}
setTruncatedPrompt(needsTruncation ? truncated + '…' : prompt);
}, [task.prompt]);
useEffect(() => {
computeTruncation();
const container = promptLineRef.current;
if (!container) return;
const ro = new ResizeObserver(() => computeTruncation());
ro.observe(container);
return () => ro.disconnect();
}, [computeTruncation]);
const isGenerating = task.status === 'generating'; const isGenerating = task.status === 'generating';
const hasResult = task.status === 'completed' && !!task.resultUrl;
const handleVideoMouseEnter = () => {
setVideoHover(true);
const v = videoRef.current;
if (!v) return;
v.muted = false;
v.play().catch(() => {
// Browser blocks unmuted autoplay — fallback to muted
v.muted = true;
v.play().catch(() => {});
});
};
const handleVideoMouseLeave = () => {
setVideoHover(false);
const v = videoRef.current;
if (!v) return;
v.pause();
v.currentTime = 0;
};
const handleDownload = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!task.resultUrl) return;
try {
const res = await fetch(task.resultUrl);
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `airdrama-${task.id}.mp4`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 10000);
} catch {
const a = document.createElement('a');
a.href = task.resultUrl;
a.download = `airdrama-${task.id}.mp4`;
a.click();
}
};
const handleCopyPrompt = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(task.prompt).then(() => {
showToast('已复制');
});
};
const handleVideoClick = () => {
if (hasResult && onOpenDetail) {
onOpenDetail(task);
}
};
return ( return (
<div className={styles.card}> <div className={styles.card}>
{/* Header: avatar + prompt */} {/* Header: reference thumbnails + prompt + meta labels */}
<div className={styles.header}> <div className={styles.header}>
<div className={styles.avatar}> {/* Left: reference thumbnails */}
<VideoIcon />
</div>
<div className={styles.headerContent}>
<p className={styles.prompt}>{task.prompt || '(无文字描述)'}</p>
<div className={styles.meta}>
<span>{task.model === 'seedance_2.0' ? 'Seedance 2.0' : 'Seedance 2.0 Fast'}</span>
<span className={styles.metaDot}>|</span>
<span>{task.duration}s</span>
<span className={styles.metaDot}>|</span>
<span>{task.aspectRatio}</span>
</div>
</div>
</div>
{/* Content area */}
<div className={styles.content}>
{/* Reference thumbnails (small) */}
{task.references.length > 0 && ( {task.references.length > 0 && (
<div className={styles.refRow}> <div className={styles.refColumn}>
{task.references.map((ref) => ( {task.references.map((ref) => (
<div key={ref.id} className={styles.refThumb}> <div key={ref.id} className={styles.refThumb}>
{ref.type === 'video' ? ( {ref.type === 'video' ? (
<video src={ref.previewUrl} className={styles.refMedia} muted /> <video src={ref.previewUrl} className={styles.refMedia} muted />
) : ref.type === 'audio' ? (
<div className={styles.audioThumb}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
</div>
) : ( ) : (
<img src={ref.previewUrl} alt={ref.label} className={styles.refMedia} /> <img src={ref.previewUrl} alt={ref.label} className={styles.refMedia} />
)} )}
@ -76,55 +210,154 @@ export function GenerationCard({ task }: Props) {
))} ))}
</div> </div>
)} )}
{/* Right: prompt + inline labels */}
<div className={styles.headerRight}>
<div
className={styles.promptWrapper}
onMouseLeave={() => setPromptHover(false)}
>
<div ref={promptLineRef} className={styles.promptLine}>
<span
onMouseEnter={() => setPromptHover(true)}
>{truncatedPrompt || '(无文字描述)'}</span>
<span ref={labelsRef} className={styles.labelsInline} onMouseEnter={() => setPromptHover(false)}>
<span className={styles.label}>
{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
ref={detailLinkRef}
className={styles.detailLink}
onMouseEnter={() => {
const el = detailLinkRef.current;
if (el) {
const rect = el.getBoundingClientRect();
setDetailPos({
top: rect.bottom + 8,
right: window.innerWidth - rect.right,
});
}
setDetailHover(true);
}}
onMouseLeave={() => setDetailHover(false)}
>
{detailHover && (
<div className={styles.detailTooltip} style={{ top: detailPos.top, right: detailPos.right }}>
<div className={styles.detailRow}>
<span></span><span>{task.aspectRatio}</span>
</div>
<div className={styles.detailRow}>
<span></span><span>{task.duration}s</span>
</div>
<div className={styles.detailRow}>
<span></span><span>720p</span>
</div>
<div className={styles.detailRow}>
<span></span>
<span>{task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'}</span>
</div>
<div className={styles.detailRow}>
<span></span>
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
</div>
</div>
)}
</span>
</span>
</div>
{promptHover && task.prompt && (
<div className={styles.promptTooltip}>
<p className={styles.promptTooltipText}>{task.prompt}</p>
<button className={styles.copyBtn} onClick={handleCopyPrompt}></button>
</div>
)}
</div>
</div>
</div>
{/* Generation result or loading */} {/* Video / result area */}
<div className={styles.resultArea}> <div className={styles.content}>
{isGenerating ? ( {isGenerating ? (
<div className={styles.resultArea}>
<div className={styles.shimmerBg} />
<div className={styles.generating}> <div className={styles.generating}>
<div className={styles.loadingSpinner} /> <div className={styles.loadingSpinner} />
<span className={styles.loadingText}>...</span> <span className={styles.loadingText}></span>
<div className={styles.progressBar}> <div className={styles.progressBar}>
<div <div
className={styles.progressFill} className={styles.progressFill}
style={{ width: `${task.progress}%` }} style={{ width: `${task.progress}%` }}
/> />
</div> </div>
<span className={styles.progressText}>{task.progress}%</span>
</div>
</div> </div>
) : task.status === 'failed' ? ( ) : task.status === 'failed' ? (
<div className={styles.resultPlaceholder}> <p className={styles.errorText}>{task.errorMessage || '生成失败,请重试'}</p>
<span style={{ color: '#e74c3c' }}>{task.errorMessage || '生成失败'}</span>
</div>
) : task.resultUrl ? ( ) : task.resultUrl ? (
<div
className={styles.resultArea}
onMouseEnter={handleVideoMouseEnter}
onMouseLeave={handleVideoMouseLeave}
onClick={handleVideoClick}
style={{ cursor: 'pointer' }}
>
<video <video
ref={videoRef}
src={task.resultUrl} src={task.resultUrl}
controls
className={styles.resultMedia} className={styles.resultMedia}
style={{ maxWidth: '100%', borderRadius: 8 }} loop
preload="metadata"
/> />
{videoHover && (
<div className={styles.videoOverlay}>
<button className={styles.downloadBtn} onClick={handleDownload}>
<DownloadIcon />
</button>
</div>
)}
</div>
) : ( ) : (
<div className={styles.resultArea}>
<div className={styles.resultPlaceholder}> <div className={styles.resultPlaceholder}>
<VideoIcon /> <VideoIcon />
<span></span> <span></span>
</div> </div>
</div>
)} )}
</div> </div>
</div>
{/* Action buttons */} {/* Bottom action buttons */}
{!isGenerating && ( {!isGenerating && (
<div className={styles.actions}> <div className={styles.actions}>
<button className={styles.actionBtn} onClick={() => reEdit(task.id)}> <button className={styles.actionBtn} onClick={() => reEdit(task.id)}>
<EditIcon /> <EditIcon /> <span></span>
<span></span>
</button> </button>
<button className={styles.actionBtn} onClick={() => regenerate(task.id)}> <button className={styles.actionBtn} onClick={() => regenerate(task.id)}>
<RefreshIcon /> <RefreshIcon /> <span></span>
<span></span>
</button> </button>
<button className={`${styles.actionBtn} ${styles.deleteBtn}`} onClick={() => removeTask(task.id)}> <div className={styles.moreMenu} ref={moreRef}>
<TrashIcon /> <button className={styles.moreBtn} onClick={() => setShowMore(!showMore)}>
<span></span> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="5" cy="12" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="19" cy="12" r="2" />
</svg>
</button> </button>
{showMore && (
<div className={styles.moreDropdown}>
<button onClick={() => { removeTask(task.id); setShowMore(false); }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
)}
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -11,7 +11,6 @@ export function InputBar() {
const mode = useInputBarStore((s) => s.mode); const mode = useInputBarStore((s) => s.mode);
const addReferences = useInputBarStore((s) => s.addReferences); const addReferences = useInputBarStore((s) => s.addReferences);
const setFirstFrame = useInputBarStore((s) => s.setFirstFrame); const setFirstFrame = useInputBarStore((s) => s.setFirstFrame);
const references = useInputBarStore((s) => s.references);
const barRef = useRef<HTMLDivElement>(null); const barRef = useRef<HTMLDivElement>(null);
const handleDragOver = useCallback((e: DragEvent) => { const handleDragOver = useCallback((e: DragEvent) => {
@ -32,18 +31,30 @@ export function InputBar() {
if (barRef.current) { if (barRef.current) {
barRef.current.style.borderColor = '#2a2a38'; barRef.current.style.borderColor = '#2a2a38';
} }
const IMAGE_MAX = 20 * 1024 * 1024; const IMAGE_MAX = 30 * 1024 * 1024;
const VIDEO_MAX = 100 * 1024 * 1024; const VIDEO_MAX = 50 * 1024 * 1024;
const AUDIO_MAX = 15 * 1024 * 1024;
const files = Array.from(e.dataTransfer.files).filter( const files = Array.from(e.dataTransfer.files).filter(
(f) => f.type.startsWith('image/') || f.type.startsWith('video/') (f) => f.type.startsWith('image/') || f.type.startsWith('video/') || f.type.startsWith('audio/')
); );
if (!files.length) return; if (!files.length) return;
const valid: File[] = []; const valid: File[] = [];
for (const f of files) { for (const f of files) {
const limit = f.type.startsWith('video/') ? VIDEO_MAX : IMAGE_MAX; 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) { if (f.size > limit) {
showToast(f.type.startsWith('video/') ? '视频文件不能超过100MB' : '图片文件不能超过20MB'); showToast(limitLabel);
} else { } else {
valid.push(f); valid.push(f);
} }
@ -51,19 +62,14 @@ export function InputBar() {
if (!valid.length) return; if (!valid.length) return;
if (mode === 'universal') { if (mode === 'universal') {
const remaining = 5 - references.length;
if (remaining <= 0) {
showToast('最多上传5张参考内容');
return;
}
addReferences(valid); addReferences(valid);
if (valid.length > remaining) {
showToast('最多上传5张参考内容');
}
} else { } else {
setFirstFrame(valid[0]); const imageFiles = valid.filter((f) => f.type.startsWith('image/'));
if (imageFiles.length > 0) {
setFirstFrame(imageFiles[0]);
} }
}, [mode, references.length, addReferences, setFirstFrame]); }
}, [mode, addReferences, setFirstFrame]);
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>

View File

@ -10,8 +10,8 @@
} }
.trigger { .trigger {
width: var(--thumbnail-size);
height: var(--thumbnail-size); height: var(--thumbnail-size);
aspect-ratio: 3 / 4;
border: 1.5px dashed #3a3a48; border: 1.5px dashed #3a3a48;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border-radius: var(--radius-btn); border-radius: var(--radius-btn);
@ -47,8 +47,8 @@
.thumbItem { .thumbItem {
position: relative; position: relative;
width: var(--thumbnail-size);
height: var(--thumbnail-size); height: var(--thumbnail-size);
aspect-ratio: 3 / 4;
border-radius: var(--radius-thumbnail); border-radius: var(--radius-thumbnail);
overflow: hidden; overflow: hidden;
background: #1a1a24; background: #1a1a24;

View File

@ -13,7 +13,7 @@
line-height: 1.6; line-height: 1.6;
width: 100%; width: 100%;
min-height: 24px; min-height: 24px;
max-height: 144px; max-height: 202px;
font-family: 'Noto Sans SC', system-ui, sans-serif; font-family: 'Noto Sans SC', system-ui, sans-serif;
overflow-y: auto; overflow-y: auto;
white-space: pre-wrap; white-space: pre-wrap;
@ -33,12 +33,13 @@
/* @ mention tag (inserted as contentEditable=false span) */ /* @ mention tag (inserted as contentEditable=false span) */
.mention { .mention {
display: inline; display: inline-block;
white-space: nowrap;
padding: 1px 6px; padding: 1px 6px;
margin: 0 2px; margin: 0 2px;
border-radius: 4px; border-radius: 4px;
background: rgba(0, 184, 230, 0.12); background: rgba(108, 99, 255, 0.12);
color: rgba(0, 184, 230, 0.7); color: rgba(108, 99, 255, 0.7);
font-size: 13px; font-size: 13px;
cursor: default; cursor: default;
user-select: none; user-select: none;
@ -46,21 +47,22 @@
} }
.mention:hover { .mention:hover {
background: rgba(0, 184, 230, 0.22); background: rgba(108, 99, 255, 0.22);
color: rgba(0, 184, 230, 0.9); color: rgba(108, 99, 255, 0.9);
} }
/* Mention popup — appears above cursor */ /* Mention popup — appears above cursor */
.mentionPopup { .mentionPopup {
position: absolute; position: absolute;
z-index: 100; z-index: 100;
background: #1e1e2e; background: rgba(13, 13, 26, 0.92);
border: 1px solid #2a2a3a; border: 1px solid rgba(255, 255, 255, 0.10);
border-radius: 10px; border-radius: 10px;
padding: 6px; padding: 6px;
min-width: 200px; min-width: 200px;
max-width: 280px; max-width: 280px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
backdrop-filter: blur(20px) saturate(180%);
transform: translateY(-100%); transform: translateY(-100%);
animation: fadeInUp 0.12s ease; animation: fadeInUp 0.12s ease;
} }
@ -96,6 +98,11 @@
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
} }
.mentionItemActive {
background: rgba(108, 99, 255, 0.15);
color: #f1f0ff;
}
.mentionThumb { .mentionThumb {
width: 36px; width: 36px;
height: 36px; height: 36px;

View File

@ -21,6 +21,7 @@ export function PromptInput() {
const [showMentionPopup, setShowMentionPopup] = useState(false); const [showMentionPopup, setShowMentionPopup] = useState(false);
const [mentionPos, setMentionPos] = useState({ top: 0, left: 0 }); const [mentionPos, setMentionPos] = useState({ top: 0, left: 0 });
const typedAtRef = useRef(false); // tracks if popup was triggered by typing @ const typedAtRef = useRef(false); // tracks if popup was triggered by typing @
const [highlightedIdx, setHighlightedIdx] = useState(0);
const [hoverRef, setHoverRef] = useState<UploadedFile | null>(null); const [hoverRef, setHoverRef] = useState<UploadedFile | null>(null);
const [hoverPos, setHoverPos] = useState({ top: 0, left: 0 }); const [hoverPos, setHoverPos] = useState({ top: 0, left: 0 });
@ -137,6 +138,7 @@ export function PromptInput() {
} }
setMentionPos({ top, left }); setMentionPos({ top, left });
setHighlightedIdx(0);
setShowMentionPopup(true); setShowMentionPopup(true);
}, []); }, []);
@ -220,17 +222,36 @@ export function PromptInput() {
}, [extractText]); }, [extractText]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (showMentionPopup && e.key === 'Escape') { if (showMentionPopup) {
if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
setShowMentionPopup(false); setShowMentionPopup(false);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightedIdx((prev) => (prev + 1) % references.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightedIdx((prev) => (prev - 1 + references.length) % references.length);
} else if (e.key === 'Enter') {
e.preventDefault();
insertMention(references[highlightedIdx]);
} }
}, [showMentionPopup]); }
}, [showMentionPopup, references, highlightedIdx, insertMention]);
const handlePaste = useCallback((e: React.ClipboardEvent) => { const handlePaste = useCallback((e: React.ClipboardEvent) => {
e.preventDefault(); e.preventDefault();
// Always paste as plain text for reliability, then rebuild mentions
const text = e.clipboardData.getData('text/plain'); const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text); document.execCommand('insertText', false, text);
}, []);
// Rebuild @label patterns (e.g. @图片1) into styled mention spans
const el = editorRef.current;
if (el && references.length > 0) {
rebuildMentionSpans(el);
}
extractText();
}, [extractText, references, rebuildMentionSpans]);
// Mention hover — delegated event // Mention hover — delegated event
const handleMouseOver = useCallback((e: React.MouseEvent) => { const handleMouseOver = useCallback((e: React.MouseEvent) => {
@ -296,7 +317,7 @@ export function PromptInput() {
{references.map((ref, idx) => ( {references.map((ref, idx) => (
<button <button
key={`${ref.id}-${idx}`} key={`${ref.id}-${idx}`}
className={styles.mentionItem} className={`${styles.mentionItem} ${idx === highlightedIdx ? styles.mentionItemActive : ''}`}
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
insertMention(ref); insertMention(ref);

View File

@ -1,8 +1,9 @@
.sidebar { .sidebar {
width: 60px; width: 76px;
height: 100%; height: 100%;
background: var(--color-sidebar-bg); background: var(--color-sidebar-bg);
border-right: 1px solid #1a1a24; backdrop-filter: blur(16px) saturate(160%);
border-right: 1px solid rgba(255, 255, 255, 0.08);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -12,14 +13,18 @@
} }
.logo { .logo {
margin-bottom: 24px; margin-bottom: 28px;
cursor: pointer; cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
} }
.navItems { .navItems {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 4px;
} }
.navItem { .navItem {
@ -27,27 +32,105 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
padding: 12px 0; padding: 10px 0;
color: #5a5a6a; width: 56px;
border-radius: 8px;
color: var(--color-text-disabled);
cursor: pointer; cursor: pointer;
transition: color 0.15s; transition: color 0.15s, background 0.15s;
font-size: 11px; font-size: 11px;
user-select: none;
} }
.navItem:hover { .navItem:hover {
color: #b0b0c0; color: var(--color-text-secondary);
background: rgba(255, 255, 255, 0.04);
} }
.navItem.active { .navItem.active {
color: var(--color-primary); color: var(--color-primary);
background: var(--color-sidebar-active);
} }
.bottomItems { /* Bottom section */
.bottom {
margin-top: auto; margin-top: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 4px; gap: 12px;
padding-bottom: 8px;
}
/* Quota display */
.quota {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
cursor: pointer;
padding: 8px 4px;
border-radius: 8px;
transition: background 0.15s;
}
.quota:hover {
background: rgba(255, 255, 255, 0.04);
}
.diamondIcon {
flex-shrink: 0;
}
.quotaNumber {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
line-height: 1;
}
.quotaLabel {
font-size: 9px;
color: var(--color-text-secondary);
white-space: nowrap;
}
/* Admin button */
.adminBtn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
color: var(--color-text-disabled);
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.adminBtn:hover {
color: var(--color-primary);
background: rgba(255, 255, 255, 0.06);
}
/* User avatar */
.avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.avatar:hover {
opacity: 0.85;
} }
@media (max-width: 767px) { @media (max-width: 767px) {

View File

@ -1,73 +1,86 @@
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../store/auth';
import styles from './Sidebar.module.css'; import styles from './Sidebar.module.css';
const sidebarItems = [
{
name: '灵感',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1" />
</svg>
),
},
{
name: '生成',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
),
active: true,
},
{
name: '资产',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M3 9h18M9 3v18" />
</svg>
),
},
{
name: '画布',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg>
),
},
];
export function Sidebar() { export function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const user = useAuthStore((s) => s.user);
const quota = useAuthStore((s) => s.quota);
const isActive = (path: string) => location.pathname === path;
const dailyRemaining = quota
? Math.max(0, quota.daily_seconds_limit - quota.daily_seconds_used)
: 0;
return ( return (
<aside className={styles.sidebar}> <aside className={styles.sidebar}>
<div className={styles.logo}> {/* Logo */}
<svg width="28" height="28" viewBox="0 0 28 28" fill="none"> <div className={styles.logo} onClick={() => navigate('/')}>
<path d="M4 8L14 2L24 8V20L14 26L4 20V8Z" fill="#00b8e6" opacity="0.9" /> <svg width="32" height="32" viewBox="0 0 28 28" fill="none">
<path d="M14 2L24 8L14 14L4 8L14 2Z" fill="#33ccf0" /> <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" /> <path d="M10 10L18 6" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" opacity="0.6" />
</svg> </svg>
</div> </div>
<div className={styles.navItems}> {/* Nav items */}
{sidebarItems.map((item) => ( <nav className={styles.navItems}>
<div <div
key={item.name} className={`${styles.navItem} ${isActive('/') ? styles.active : ''}`}
className={`${styles.navItem} ${item.active ? styles.active : ''}`} onClick={() => navigate('/')}
title={item.name}
> >
{item.icon} <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<span>{item.name}</span> <path d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span></span>
</div> </div>
))} <div
className={`${styles.navItem} ${isActive('/assets') ? styles.active : ''}`}
onClick={() => navigate('/assets')}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M3 9h18M9 3v18" />
</svg>
<span></span>
</div>
</nav>
{/* Bottom section: quota + avatar + admin */}
<div className={styles.bottom}>
{/* Quota display */}
<div className={styles.quota} onClick={() => navigate('/profile')}>
<svg className={styles.diamondIcon} width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M6 3h12l4 8-10 12L2 11l4-8z" fill="#6c63ff" opacity="0.85" />
<path d="M2 11h20M6 3l4 8M18 3l-4 8M12 23l-4-12M12 23l4-12" stroke="#fff" strokeWidth="0.8" opacity="0.4" />
</svg>
<span className={styles.quotaNumber}>{dailyRemaining}</span>
<span className={styles.quotaLabel}></span>
</div> </div>
<div className={styles.bottomItems}> {/* Admin entry */}
<div className={styles.navItem} title="API"> {user?.is_staff && (
<div
className={styles.adminBtn}
onClick={() => navigate('/admin/dashboard')}
title="管理后台"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /> <circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg> </svg>
<span style={{ fontSize: 10 }}>API</span> </div>
)}
{/* User avatar */}
<div
className={styles.avatar}
onClick={() => navigate('/profile')}
title={user?.username || '个人中心'}
>
{user?.username?.charAt(0).toUpperCase() || 'U'}
</div> </div>
</div> </div>
</aside> </aside>

View File

@ -1,6 +1,6 @@
.toast { .toast {
position: fixed; position: fixed;
top: 60px; top: 20px;
left: 50%; left: 50%;
transform: translateX(-50%) translateY(-20px); transform: translateX(-50%) translateY(-20px);
background: var(--color-bg-dropdown); background: var(--color-bg-dropdown);

View File

@ -39,16 +39,6 @@
flex: 1; flex: 1;
} }
.credits {
display: flex;
align-items: center;
gap: 4px;
color: var(--color-text-secondary);
font-size: 12px;
margin-right: 8px;
opacity: 0.7;
}
.sendBtn { .sendBtn {
width: var(--send-btn-size); width: var(--send-btn-size);
height: var(--send-btn-size); height: var(--send-btn-size);

View File

@ -70,7 +70,8 @@ const generationTypeItems = [
]; ];
const modelItems = [ const modelItems = [
{ label: 'Seedance 2.0', value: 'seedance_2.0' as ModelOption, icon: <DiamondIcon /> }, { label: 'AirDrama', value: 'seedance_2.0' as ModelOption, icon: <DiamondIcon /> },
{ label: 'AirDrama Fast', value: 'seedance_2.0_fast' as ModelOption, icon: <LightningIcon /> },
]; ];
const modeItems = [ const modeItems = [
@ -79,12 +80,12 @@ const modeItems = [
]; ];
const ratioItems = [ const ratioItems = [
{ label: '16:9', value: '16:9' as AspectRatio },
{ label: '9:16', value: '9:16' as AspectRatio },
{ label: '1:1', value: '1:1' as AspectRatio },
{ label: '21:9', value: '21:9' as AspectRatio }, { label: '21:9', value: '21:9' as AspectRatio },
{ label: '16:9', value: '16:9' as AspectRatio },
{ label: '4:3', value: '4:3' as AspectRatio }, { label: '4:3', value: '4:3' as AspectRatio },
{ label: '1:1', value: '1:1' as AspectRatio },
{ label: '3:4', value: '3:4' as AspectRatio }, { label: '3:4', value: '3:4' as AspectRatio },
{ label: '9:16', value: '9:16' as AspectRatio },
]; ];
const durationItems = Array.from({ length: 12 }, (_, i) => { const durationItems = Array.from({ length: 12 }, (_, i) => {
@ -143,11 +144,20 @@ export function Toolbar() {
<span className={styles.label}></span> <span className={styles.label}></span>
</button> </button>
{/* Model — fixed to Seedance 2.0 */} {/* Model selector */}
<Dropdown
items={modelItems}
value={model}
onSelect={(v) => setModel(v as ModelOption)}
minWidth={160}
trigger={
<button className={styles.btn}> <button className={styles.btn}>
<DiamondIcon /> {model === 'seedance_2.0_fast' ? <LightningIcon /> : <DiamondIcon />}
<span className={styles.label}>Seedance 2.0</span> <span className={styles.label}>{model === 'seedance_2.0_fast' ? 'AirDrama Fast' : 'AirDrama'}</span>
<ChevronDown />
</button> </button>
}
/>
{/* Mode selector */} {/* Mode selector */}
<Dropdown <Dropdown
@ -209,15 +219,6 @@ export function Toolbar() {
{/* Spacer */} {/* Spacer */}
<div className={styles.spacer} /> <div className={styles.spacer} />
{/* Credits indicator */}
<div className={styles.credits}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
<span>30</span>
</div>
{/* Send button */} {/* Send button */}
<button <button
className={`${styles.sendBtn} ${isSubmittable ? styles.sendEnabled : styles.sendDisabled}`} className={`${styles.sendBtn} ${isSubmittable ? styles.sendEnabled : styles.sendDisabled}`}

View File

@ -1,8 +1,15 @@
.wrapper { .wrapper {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
gap: 8px;
align-items: flex-start; align-items: flex-start;
position: relative;
z-index: 10;
}
/* hasFiles state: fixed dimensions via inline style, overflow visible for expanded content */
.wrapperActive {
overflow: visible;
align-self: flex-start;
} }
.hiddenInput { .hiddenInput {
@ -10,8 +17,8 @@
} }
.trigger { .trigger {
width: var(--thumbnail-size);
height: var(--thumbnail-size); height: var(--thumbnail-size);
aspect-ratio: 3 / 4;
border: 1.5px dashed #3a3a48; border: 1.5px dashed #3a3a48;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border-radius: var(--radius-btn); border-radius: var(--radius-btn);
@ -35,32 +42,56 @@
color: var(--color-text-disabled); color: var(--color-text-disabled);
} }
/* Single row container for all thumbnails */ /* Always absolute — no position toggling to avoid jitter */
.thumbRow { .thumbRow {
position: absolute;
top: 0;
left: 0;
display: flex; display: flex;
align-items: flex-end; align-items: flex-start;
flex-shrink: 0; flex-shrink: 0;
position: relative;
} }
/* Each thumbnail card */ /* Expanded: overlay on top of prompt text */
.thumbRowExpanded {
z-index: 11;
padding-right: 12px;
}
/* Add-more button gets opaque background when expanded (overlays prompt text) */
.thumbRowExpanded .addMore {
background: #16161e;
border-color: #3a3a48;
}
.thumbRowExpanded .addMore:hover {
background: #1e1e2a;
border-color: #5a5a6a;
}
/* Each thumbnail card — 3:4 portrait ratio, overflow visible for tooltip */
.thumbItem { .thumbItem {
position: relative; position: relative;
width: var(--thumbnail-size);
height: var(--thumbnail-size); height: var(--thumbnail-size);
aspect-ratio: 3 / 4;
overflow: visible;
flex-shrink: 0;
cursor: default;
transition: margin-left 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Inner container: clips media, label, close button within rounded corners */
.thumbInner {
width: 100%;
height: 100%;
border-radius: var(--radius-thumbnail); border-radius: var(--radius-thumbnail);
overflow: hidden; overflow: hidden;
background: #1a1a24; background: #1a1a24;
flex-shrink: 0;
border: 1.5px solid #2a2a38; border: 1.5px solid #2a2a38;
cursor: default; position: relative;
transition:
margin-left 0.35s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.35s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s;
} }
.thumbItem:hover { .thumbItem:hover .thumbInner {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
} }
@ -71,7 +102,7 @@
display: block; display: block;
} }
/* Close / remove button */ /* Close / remove button — inside thumbInner */
.thumbClose { .thumbClose {
position: absolute; position: absolute;
top: 4px; top: 4px;
@ -97,7 +128,30 @@
opacity: 1; opacity: 1;
} }
/* Label at bottom of thumbnail */ /* Tooltip above thumbnail on hover — outside thumbInner */
.thumbTooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
padding: 4px 10px;
border-radius: 6px;
background: rgba(13, 13, 26, 0.92);
border: 1px solid rgba(255, 255, 255, 0.10);
color: var(--color-text-primary);
font-size: 12px;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
z-index: 20;
}
.itemExpanded:hover .thumbTooltip {
opacity: 1;
}
/* Label at bottom of thumbnail — inside thumbInner */
.thumbLabel { .thumbLabel {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
@ -113,16 +167,18 @@
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
border-radius: 0 0 var(--radius-thumbnail) var(--radius-thumbnail);
} }
.itemExpanded .thumbLabel { .itemExpanded .thumbLabel {
opacity: 1; opacity: 1;
} }
/* Add more button */ /* Add more button — 3:4 to match thumbnails */
.addMore { .addMore {
width: var(--thumbnail-size); position: relative;
height: var(--thumbnail-size); height: var(--thumbnail-size);
aspect-ratio: 3 / 4;
border: 1.5px dashed #3a3a48; border: 1.5px dashed #3a3a48;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border-radius: var(--radius-btn); border-radius: var(--radius-btn);
@ -133,6 +189,7 @@
gap: 4px; gap: 4px;
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
overflow: visible;
transition: transition:
margin-left 0.35s cubic-bezier(0.4, 0, 0.2, 1), margin-left 0.35s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.25s, opacity 0.25s,
@ -145,32 +202,82 @@
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
} }
.addMoreHidden {
opacity: 0;
pointer-events: none;
}
.addMoreVisible { .addMoreVisible {
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
/* Count badge shown in collapsed state */ /* Tooltip for add-more button */
.addMoreTooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
padding: 4px 10px;
border-radius: 6px;
background: rgba(13, 13, 26, 0.92);
border: 1px solid rgba(255, 255, 255, 0.10);
color: var(--color-text-primary);
font-size: 12px;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
z-index: 20;
}
.addMore:hover .addMoreTooltip {
opacity: 1;
}
/* "+" badge — positioned relative to .wrapper, left set via inline style */
.countBadge { .countBadge {
position: absolute; position: absolute;
bottom: -2px; bottom: -6px;
right: -6px; width: 28px;
min-width: 18px; height: 28px;
height: 18px; border-radius: 50%;
padding: 0 5px; background: rgba(255, 255, 255, 0.15);
border-radius: 9px; backdrop-filter: blur(4px);
background: var(--color-primary); border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff; color: #fff;
font-size: 11px; font-size: 16px;
font-weight: 600; font-weight: 400;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 100; z-index: 100;
pointer-events: none; cursor: pointer;
transition: background 0.15s;
}
.countBadge:hover {
background: rgba(255, 255, 255, 0.25);
}
/* Tooltip for "+" badge */
.badgeTooltip {
position: absolute;
bottom: calc(100% + 6px);
right: -8px;
white-space: nowrap;
padding: 4px 10px;
border-radius: 6px;
background: rgba(13, 13, 26, 0.92);
border: 1px solid rgba(255, 255, 255, 0.10);
color: var(--color-text-primary);
font-size: 12px;
pointer-events: none;
z-index: 101;
}
/* Audio placeholder icon */
.audioPlaceholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #1a1a24;
color: var(--color-text-secondary);
} }

View File

@ -3,12 +3,29 @@ import { useInputBarStore } from '../store/inputBar';
import { showToast } from './Toast'; import { showToast } from './Toast';
import styles from './UniversalUpload.module.css'; import styles from './UniversalUpload.module.css';
const MAX_IMAGE_SIZE = 30 * 1024 * 1024; // 30MB per API doc
const MAX_VIDEO_SIZE = 50 * 1024 * 1024; // 50MB per API doc
const MAX_AUDIO_SIZE = 15 * 1024 * 1024; // 15MB per API doc
const THUMB_H = 80; // matches --thumbnail-size
const THUMB_W = THUMB_H * 3 / 4; // 60px (aspect-ratio 3:4)
const PEEK = 12; // visible width per stacked card beyond the first
const AudioIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
);
export function UniversalUpload() { export function UniversalUpload() {
const references = useInputBarStore((s) => s.references); const references = useInputBarStore((s) => s.references);
const addReferences = useInputBarStore((s) => s.addReferences); const addReferences = useInputBarStore((s) => s.addReferences);
const removeReference = useInputBarStore((s) => s.removeReference); const removeReference = useInputBarStore((s) => s.removeReference);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [badgeHover, setBadgeHover] = useState(false);
const handleTrigger = () => { const handleTrigger = () => {
fileInputRef.current?.click(); fileInputRef.current?.click();
@ -18,19 +35,22 @@ export function UniversalUpload() {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
if (!files.length) return; if (!files.length) return;
const remaining = 5 - references.length;
if (remaining <= 0) {
showToast('最多上传5张参考内容');
return;
}
const IMAGE_MAX = 20 * 1024 * 1024;
const VIDEO_MAX = 100 * 1024 * 1024;
const valid: File[] = []; const valid: File[] = [];
for (const f of files) { for (const f of files) {
const limit = f.type.startsWith('video/') ? VIDEO_MAX : IMAGE_MAX; let limit: number;
let limitLabel: string;
if (f.type.startsWith('video/')) {
limit = MAX_VIDEO_SIZE;
limitLabel = '视频文件不能超过50MB';
} else if (f.type.startsWith('audio/')) {
limit = MAX_AUDIO_SIZE;
limitLabel = '音频文件不能超过15MB';
} else {
limit = MAX_IMAGE_SIZE;
limitLabel = '图片文件不能超过30MB';
}
if (f.size > limit) { if (f.size > limit) {
showToast(f.type.startsWith('video/') ? '视频文件不能超过100MB' : '图片文件不能超过20MB'); showToast(limitLabel);
} else { } else {
valid.push(f); valid.push(f);
} }
@ -38,21 +58,29 @@ export function UniversalUpload() {
if (!valid.length) { e.target.value = ''; return; } if (!valid.length) { e.target.value = ''; return; }
addReferences(valid); addReferences(valid);
if (valid.length > remaining) {
showToast('最多上传5张参考内容');
}
e.target.value = ''; e.target.value = '';
}; };
const hasFiles = references.length > 0; const hasFiles = references.length > 0;
const count = references.length; const count = references.length;
// Check if all type slots are full
const counts = { image: 0, video: 0, audio: 0 };
for (const ref of references) counts[ref.type]++;
const allFull = counts.image >= 9 && counts.video >= 3 && counts.audio >= 3;
// Collapsed stack visual width
const stackWidth = THUMB_W + Math.max(0, count - 1) * PEEK;
return ( return (
<div className={styles.wrapper}> <div
className={`${styles.wrapper} ${hasFiles ? styles.wrapperActive : ''}`}
style={hasFiles ? { width: stackWidth, height: THUMB_H } : undefined}
>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="image/*,video/*" accept="image/*,video/*,audio/*"
multiple multiple
className={styles.hiddenInput} className={styles.hiddenInput}
onChange={handleFileChange} onChange={handleFileChange}
@ -69,10 +97,11 @@ export function UniversalUpload() {
</div> </div>
)} )}
{/* Thumbnails row - single container, animate via CSS transitions */} {/* Thumbnails — thumbRow always absolute, hover to expand */}
{hasFiles && ( {hasFiles && (
<>
<div <div
className={styles.thumbRow} className={`${styles.thumbRow} ${expanded ? styles.thumbRowExpanded : ''}`}
onMouseEnter={() => setExpanded(true)} onMouseEnter={() => setExpanded(true)}
onMouseLeave={() => setExpanded(false)} onMouseLeave={() => setExpanded(false)}
> >
@ -81,15 +110,17 @@ export function UniversalUpload() {
key={ref.id} key={ref.id}
className={`${styles.thumbItem} ${expanded ? styles.itemExpanded : ''}`} className={`${styles.thumbItem} ${expanded ? styles.itemExpanded : ''}`}
style={{ style={{
marginLeft: i === 0 ? 0 : (expanded ? 8 : -64), marginLeft: i === 0 ? 0 : (expanded ? 8 : -48),
zIndex: expanded ? 1 : count - i, zIndex: expanded ? 1 : count - i,
transform: expanded
? 'rotate(0deg) translateY(0px)'
: `rotate(${i * -2.5}deg) translateY(${i * -2}px)`,
}} }}
> >
<div className={styles.thumbInner}>
{ref.type === 'video' ? ( {ref.type === 'video' ? (
<video src={ref.previewUrl} className={styles.thumbMedia} muted /> <video src={ref.previewUrl} className={styles.thumbMedia} muted />
) : ref.type === 'audio' ? (
<div className={styles.audioPlaceholder}>
<AudioIcon />
</div>
) : ( ) : (
<img src={ref.previewUrl} alt={ref.label} className={styles.thumbMedia} /> <img src={ref.previewUrl} alt={ref.label} className={styles.thumbMedia} />
)} )}
@ -104,30 +135,43 @@ export function UniversalUpload() {
</div> </div>
<div className={styles.thumbLabel}>{ref.label}</div> <div className={styles.thumbLabel}>{ref.label}</div>
</div> </div>
<div className={styles.thumbTooltip}>{ref.label}</div>
</div>
))} ))}
{/* Add more button */} {/* Add more button (expanded state only) */}
{references.length < 5 && ( {expanded && !allFull && (
<div <div
className={`${styles.addMore} ${expanded ? styles.addMoreVisible : styles.addMoreHidden}`} className={`${styles.addMore} ${styles.addMoreVisible}`}
style={{ style={{ marginLeft: 8 }}
marginLeft: expanded ? 8 : -64,
}}
onClick={(e) => { e.stopPropagation(); handleTrigger(); }} onClick={(e) => { e.stopPropagation(); handleTrigger(); }}
> >
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#5a5a6a" strokeWidth="1.5" strokeLinecap="round"> <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#5a5a6a" strokeWidth="1.5" strokeLinecap="round">
<line x1="12" y1="5" x2="12" y2="19" /> <line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" /> <line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
<div className={styles.addMoreTooltip}></div>
</div> </div>
)} )}
</div>
{/* Count badge when collapsed */} {/* "+" badge — outside thumbRow, position based on stack width */}
{!expanded && count > 1 && ( {!expanded && !allFull && (
<div className={styles.countBadge}>{count}</div> <div
className={styles.countBadge}
style={{ left: stackWidth - 14 }}
onClick={(e) => { e.stopPropagation(); handleTrigger(); }}
onMouseEnter={() => setBadgeHover(true)}
onMouseLeave={() => setBadgeHover(false)}
>
+
{badgeHover && (
<div className={styles.badgeTooltip}></div>
)} )}
</div> </div>
)} )}
</>
)}
</div> </div>
); );
} }

View File

@ -1,100 +0,0 @@
.bar {
position: fixed;
top: 0;
right: 0;
left: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 8px 24px;
background: rgba(10, 10, 15, 0.85);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--color-border-input-bar);
}
.userSection {
display: flex;
align-items: center;
gap: 10px;
}
.avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
}
.username {
color: var(--color-text-primary);
font-size: 13px;
font-weight: 500;
}
.quota {
color: var(--color-text-secondary);
font-size: 12px;
padding: 2px 8px;
background: rgba(255, 255, 255, 0.04);
border-radius: 4px;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: 16px;
}
.adminBtn {
padding: 4px 12px;
background: transparent;
border: 1px solid var(--color-border-input-bar);
border-radius: 6px;
color: var(--color-primary);
font-size: 12px;
cursor: pointer;
transition: background 0.2s;
}
.adminBtn:hover {
background: var(--color-bg-hover);
}
.logoutBtn {
padding: 4px 12px;
background: transparent;
border: 1px solid var(--color-border-input-bar);
border-radius: 6px;
color: var(--color-text-secondary);
font-size: 12px;
cursor: pointer;
transition: background 0.2s;
}
.logoutBtn:hover {
background: var(--color-bg-hover);
}
.profileBtn {
padding: 4px 12px;
background: transparent;
border: 1px solid var(--color-border-input-bar);
border-radius: 6px;
color: var(--color-text-secondary);
font-size: 12px;
cursor: pointer;
transition: background 0.2s;
}
.profileBtn:hover {
background: var(--color-bg-hover);
color: var(--color-primary);
}

View File

@ -1,46 +0,0 @@
import { useAuthStore } from '../store/auth';
import { useNavigate } from 'react-router-dom';
import styles from './UserInfoBar.module.css';
export function UserInfoBar() {
const user = useAuthStore((s) => s.user);
const quota = useAuthStore((s) => s.quota);
const logout = useAuthStore((s) => s.logout);
const navigate = useNavigate();
if (!user) return null;
const handleLogout = () => {
logout();
navigate('/login', { replace: true });
};
return (
<div className={styles.bar}>
<div className={styles.userSection}>
<div className={styles.avatar}>
{user.username.charAt(0).toUpperCase()}
</div>
<span className={styles.username}>{user.username}</span>
{quota && (
<span className={styles.quota}>
: {Math.max(quota.daily_seconds_limit - quota.daily_seconds_used, 0)}s/{quota.daily_seconds_limit}s()
</span>
)}
</div>
<div className={styles.actions}>
<button className={styles.profileBtn} onClick={() => navigate('/profile')}>
</button>
{user.is_staff && (
<button className={styles.adminBtn} onClick={() => navigate('/admin/dashboard')}>
</button>
)}
<button className={styles.logoutBtn} onClick={handleLogout}>
退
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,499 @@
.overlay {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 76px; /* sidebar width */
z-index: 200;
background: #07070f;
display: flex;
overflow: hidden;
animation: overlayIn 0.2s ease-out;
}
@keyframes overlayIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal {
position: relative;
z-index: 2;
display: flex;
width: 100%;
height: 100%;
overflow: hidden;
}
/*
Left: Video player section
*/
.playerSection {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
padding: 0 72px 20px 20px; /* right: space for close + arrows */
}
/* Close button — top-right, aligned with right panel header */
.closeBtn {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
width: 36px;
height: 36px;
background: rgba(255, 255, 255, 0.06);
border: none;
color: rgba(255, 255, 255, 0.5);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 8px;
transition: color 0.15s, background 0.15s;
}
.closeBtn:hover {
color: #fff;
background: rgba(255, 255, 255, 0.12);
}
/* Video area — centres the player */
.videoArea {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
padding: 20px 0; /* vertical breathing room, matches bottom padding */
}
/* Rounded video container — sized by JS (ResizeObserver + aspect ratio) */
.videoContainer {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
overflow: hidden;
border-radius: 16px;
background: #000;
position: relative;
}
.video {
width: 100%;
height: 100%;
object-fit: contain;
}
/* ── Controls bar — inside rounded container ── */
.controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
flex-direction: column;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
border-radius: 0 0 16px 16px;
padding: 24px 0 0;
opacity: 0;
transition: opacity 0.25s;
}
.controlsVisible {
opacity: 1;
}
/* Progress bar — full width at top of controls */
.progressTrack {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.2);
cursor: pointer;
position: relative;
flex-shrink: 0;
}
.progressTrack:hover {
height: 6px;
}
.progressFill {
height: 100%;
background: var(--color-primary);
transition: width 0.1s linear;
}
/* Controls row — below progress bar */
.controlsRow {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px 12px;
}
.controlBtn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: none;
color: #fff;
cursor: pointer;
flex-shrink: 0;
border-radius: 4px;
transition: background 0.15s;
}
.controlBtn:hover {
background: rgba(255, 255, 255, 0.1);
}
.timeDisplay {
font-size: 13px;
color: rgba(255, 255, 255, 0.8);
white-space: nowrap;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.controlsSpacer {
flex: 1;
}
/* Volume control */
.volumeControl {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.volumeSlider {
width: 72px;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.volumeSlider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff;
cursor: pointer;
}
.volumeSlider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff;
border: none;
cursor: pointer;
}
/* ── Nav arrows — in the right padding of playerSection ── */
.navArrows {
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
z-index: 10;
display: flex;
flex-direction: column;
gap: 40px;
}
.navArrowBtn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.1);
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.navArrowBtn:hover:not(.navArrowDisabled) {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
.navArrowDisabled {
opacity: 0.3;
pointer-events: none;
}
/*
Right: Info panel (glass style)
*/
.infoPanel {
width: 360px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-left: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
}
/* Header with download + icons */
.infoPanelHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.headerIcons {
display: flex;
align-items: center;
gap: 4px;
}
.iconBtn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.iconBtn:hover {
color: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.06);
}
/* More menu dropdown */
.moreMenuWrap {
position: relative;
}
.moreDropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
min-width: 120px;
background: #1a1a24;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 4px;
z-index: 20;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.moreDropdownItem {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: none;
background: none;
color: #ef4444;
font-size: 13px;
cursor: pointer;
border-radius: 6px;
font-family: inherit;
transition: background 0.15s;
}
.moreDropdownItem:hover {
background: rgba(255, 255, 255, 0.06);
}
.downloadBtn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 24px;
border-radius: 10px;
background: var(--color-primary);
color: #fff;
border: none;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
font-family: inherit;
}
.downloadBtn:hover {
opacity: 0.85;
}
/* Scrollable content area */
.infoPanelContent {
flex: 1;
overflow-y: auto;
padding: 0 24px 24px;
}
/* Prompt */
.promptSection {
padding-top: 20px;
padding-bottom: 16px;
}
.sectionLabel {
font-size: 12px;
font-weight: 500;
color: #8b8ea8;
margin-bottom: 10px;
}
.promptText {
font-size: 14px;
color: #f1f0ff;
line-height: 1.7;
word-break: break-word;
}
/* References */
.refSection {
padding-bottom: 16px;
}
.refGrid {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.refItem {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.refImg {
width: 56px;
height: 56px;
border-radius: 6px;
object-fit: cover;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.refAudioPlaceholder {
width: 56px;
height: 56px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
color: #8b8ea8;
}
.refLabel {
font-size: 10px;
color: #8b8ea8;
}
/* ── Fixed bottom section ── */
.infoPanelBottom {
flex-shrink: 0;
padding: 16px 24px 24px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
/* Compact info bar — single-line meta */
.infoBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 16px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
font-size: 13px;
color: #8b8ea8;
margin-bottom: 12px;
}
.infoBarDot {
width: 3px;
height: 3px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
flex-shrink: 0;
}
/* Action buttons — ghost style */
.cardActions {
display: flex;
gap: 10px;
}
.cardBtn {
display: inline-flex;
align-items: center;
gap: 6px;
flex: 1;
padding: 10px 0;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.10);
border-radius: 10px;
font-size: 13px;
color: #8b8ea8;
cursor: pointer;
transition: color 0.15s, background 0.15s;
font-family: inherit;
justify-content: center;
}
.cardBtn:hover {
color: #f1f0ff;
background: rgba(255, 255, 255, 0.10);
}
/*
Mobile
*/
@media (max-width: 767px) {
.overlay {
left: 0;
}
.modal {
flex-direction: column;
}
.playerSection {
flex: none;
height: 50vh;
padding: 0 56px 12px 12px;
}
.infoPanel {
flex: 1;
width: 100%;
border-left: none;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
}

View File

@ -0,0 +1,487 @@
import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
import type { GenerationTask } from '../types';
import { AmbientBackground } from './AmbientBackground';
import styles from './VideoDetailModal.module.css';
interface Props {
task: GenerationTask | null;
onClose: () => void;
onReEdit: (id: string) => void;
onRegenerate: (id: string) => void;
onDelete?: (id: string) => void;
onPrev?: () => void;
onNext?: () => void;
hasPrev?: boolean;
hasNext?: boolean;
}
export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDelete, onPrev, onNext, hasPrev, hasNext }: Props) {
const videoRef = useRef<HTMLVideoElement>(null);
const videoContainerRef = useRef<HTMLDivElement>(null);
const videoAreaRef = useRef<HTMLDivElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [showControls, setShowControls] = useState(true);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showMoreMenu, setShowMoreMenu] = useState(false);
const [fitSize, setFitSize] = useState<{ w: number; h: number } | null>(null);
const moreMenuRef = useRef<HTMLDivElement>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>();
// Parse aspect ratio from task
const arNum = useMemo(() => {
const ar = task?.aspectRatio || '16:9';
const parts = ar.split(':').map(Number);
return (parts[0] && parts[1]) ? parts[0] / parts[1] : 16 / 9;
}, [task?.aspectRatio]);
// Compute container size to fit aspect ratio within videoArea
useEffect(() => {
const el = videoAreaRef.current;
if (!el || !task) return;
const ro = new ResizeObserver(([entry]) => {
const { width: aw, height: ah } = entry.contentRect;
let w = aw;
let h = w / arNum;
if (h > ah) { h = ah; w = h * arNum; }
setFitSize({ w, h });
});
ro.observe(el);
return () => ro.disconnect();
}, [arNum, task]);
// ESC to close
useEffect(() => {
if (!task) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKey);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKey);
document.body.style.overflow = '';
};
}, [task, onClose]);
// Close more menu on outside click
useEffect(() => {
if (!showMoreMenu) return;
const handleClick = (e: MouseEvent) => {
if (moreMenuRef.current && !moreMenuRef.current.contains(e.target as Node)) {
setShowMoreMenu(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [showMoreMenu]);
// Reset playback state when task changes (for prev/next navigation)
useEffect(() => {
if (!task) return;
const v = videoRef.current;
if (v) {
v.pause();
v.currentTime = 0;
}
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setShowMoreMenu(false);
}, [task?.id]);
// Track fullscreen changes
useEffect(() => {
const handler = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', handler);
return () => document.removeEventListener('fullscreenchange', handler);
}, []);
const scheduleHideControls = useCallback(() => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
setShowControls(true);
hideTimerRef.current = setTimeout(() => {
if (isPlaying) setShowControls(false);
}, 3000);
}, [isPlaying]);
const togglePlay = () => {
const v = videoRef.current;
if (!v) return;
if (v.paused) {
v.play().catch(() => {});
} else {
v.pause();
}
};
const handleTimeUpdate = () => {
const v = videoRef.current;
if (v) setCurrentTime(v.currentTime);
};
const handleLoadedMetadata = () => {
const v = videoRef.current;
if (v) setDuration(v.duration);
};
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
const v = videoRef.current;
const bar = progressRef.current;
if (!v || !bar) return;
const rect = bar.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
v.currentTime = ratio * v.duration;
};
const toggleMute = () => {
const v = videoRef.current;
if (!v) return;
const newMuted = !isMuted;
v.muted = newMuted;
setIsMuted(newMuted);
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(e.target.value);
setVolume(val);
if (videoRef.current) {
videoRef.current.volume = val;
videoRef.current.muted = val === 0;
}
setIsMuted(val === 0);
};
const toggleFullscreen = () => {
const el = videoContainerRef.current;
if (!el) return;
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
} else {
el.requestFullscreen().catch(() => {});
}
};
const handleDownload = async () => {
if (!task?.resultUrl) return;
try {
const res = await fetch(task.resultUrl);
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `airdrama-${task.id}.mp4`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 10000);
} catch {
const a = document.createElement('a');
a.href = task.resultUrl;
a.download = `airdrama-${task.id}.mp4`;
a.click();
}
};
const handleReEdit = () => {
if (task) {
onReEdit(task.id);
onClose();
}
};
const handleRegenerate = () => {
if (task) {
onRegenerate(task.id);
onClose();
}
};
const handleDelete = () => {
if (task && onDelete) {
onDelete(task.id);
onClose();
}
setShowMoreMenu(false);
};
const formatTime = (s: number) => {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
};
if (!task) return null;
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
const modelLabel = task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast';
const modeLabel = task.mode === 'universal' ? '全能参考' : '首尾帧';
const effectiveVolume = isMuted ? 0 : volume;
return (
<div className={styles.overlay} onClick={onClose}>
<AmbientBackground />
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
{/* Left: Video player area — clicking blank space closes modal */}
<div
className={styles.playerSection}
onClick={onClose}
onMouseMove={scheduleHideControls}
onMouseEnter={scheduleHideControls}
>
{/* Close button — top-right corner of player section */}
<button className={styles.closeBtn} onClick={onClose}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/* Video area — centers the aspect-ratio-aware player */}
<div ref={videoAreaRef} className={styles.videoArea}>
<div
ref={videoContainerRef}
className={styles.videoContainer}
style={fitSize ? { width: fitSize.w, height: fitSize.h } : undefined}
onClick={(e) => { e.stopPropagation(); togglePlay(); }}
>
<video
ref={videoRef}
src={task.resultUrl}
className={styles.video}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
preload="auto"
/>
{/* Full controls bar — inside rounded container */}
<div
className={`${styles.controls} ${showControls ? styles.controlsVisible : ''}`}
onClick={(e) => e.stopPropagation()}
>
{/* Progress bar (full width, top of controls) */}
<div
ref={progressRef}
className={styles.progressTrack}
onClick={handleProgressClick}
>
<div className={styles.progressFill} style={{ width: `${progress}%` }} />
</div>
{/* Controls row */}
<div className={styles.controlsRow}>
{/* Play/Pause */}
<button className={styles.controlBtn} onClick={togglePlay}>
{isPlaying ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
)}
</button>
{/* Time */}
<span className={styles.timeDisplay}>
{formatTime(currentTime)} / {formatTime(duration)}
</span>
{/* Spacer */}
<div className={styles.controlsSpacer} />
{/* Volume */}
<div className={styles.volumeControl}>
<button className={styles.controlBtn} onClick={toggleMute}>
{effectiveVolume === 0 ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" fill="currentColor" stroke="none" />
<line x1="23" y1="9" x2="17" y2="15" />
<line x1="17" y1="9" x2="23" y2="15" />
</svg>
) : effectiveVolume < 0.5 ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" fill="currentColor" stroke="none" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" fill="currentColor" stroke="none" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
</svg>
)}
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={effectiveVolume}
onChange={handleVolumeChange}
className={styles.volumeSlider}
/>
</div>
{/* Fullscreen */}
<button className={styles.controlBtn} onClick={toggleFullscreen}>
{isFullscreen ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
</svg>
)}
</button>
</div>
</div>
</div>
</div>
{/* Prev/Next navigation arrows — outside video container */}
{(onPrev || onNext) && (
<div className={styles.navArrows}>
<button
className={`${styles.navArrowBtn} ${!hasPrev ? styles.navArrowDisabled : ''}`}
onClick={(e) => { e.stopPropagation(); hasPrev && onPrev?.(); }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="18 15 12 9 6 15" />
</svg>
</button>
<button
className={`${styles.navArrowBtn} ${!hasNext ? styles.navArrowDisabled : ''}`}
onClick={(e) => { e.stopPropagation(); hasNext && onNext?.(); }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
)}
</div>
{/* Right: Info panel */}
<div className={styles.infoPanel}>
{/* Header: download left, favorite + more right */}
<div className={styles.infoPanelHeader}>
<button className={styles.downloadBtn} onClick={handleDownload}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
<div className={styles.headerIcons}>
<button className={styles.iconBtn} title="收藏">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
</button>
<div className={styles.moreMenuWrap} ref={moreMenuRef}>
<button className={styles.iconBtn} onClick={() => setShowMoreMenu(!showMoreMenu)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="1.5" />
<circle cx="12" cy="12" r="1.5" />
<circle cx="12" cy="19" r="1.5" />
</svg>
</button>
{showMoreMenu && (
<div className={styles.moreDropdown}>
<button className={styles.moreDropdownItem} onClick={handleDelete}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
)}
</div>
</div>
</div>
{/* Scrollable content: prompt + references */}
<div className={styles.infoPanelContent}>
<div className={styles.promptSection}>
<div className={styles.sectionLabel}></div>
<p className={styles.promptText}>{task.prompt || '(无文字描述)'}</p>
</div>
{task.references.length > 0 && (
<div className={styles.refSection}>
<div className={styles.refGrid}>
{task.references.map((ref) => (
<div key={ref.id} className={styles.refItem}>
{ref.type === 'video' ? (
<video src={ref.previewUrl} className={styles.refImg} muted />
) : ref.type === 'audio' ? (
<div className={styles.refAudioPlaceholder}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
</div>
) : (
<img src={ref.previewUrl} alt={ref.label} className={styles.refImg} />
)}
<span className={styles.refLabel}>{ref.label}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Fixed bottom: info bar + actions card */}
<div className={styles.infoPanelBottom}>
<div className={styles.infoBar}>
<span>{modeLabel}</span>
<span className={styles.infoBarDot} />
<span>{modelLabel}</span>
<span className={styles.infoBarDot} />
<span>{task.duration}s</span>
<span className={styles.infoBarDot} />
<span>{task.aspectRatio}</span>
</div>
<div className={styles.cardActions}>
<button className={styles.cardBtn} onClick={handleReEdit}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button className={styles.cardBtn} onClick={handleRegenerate}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,6 +1,8 @@
.layout { .layout {
display: flex; display: flex;
height: 100%; height: 100%;
position: relative;
z-index: 2;
} }
.main { .main {

View File

@ -1,17 +1,22 @@
import { useRef, useEffect } from 'react'; import { useRef, useEffect, useState, useMemo } from 'react';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { InputBar } from './InputBar'; import { InputBar } from './InputBar';
import { GenerationCard } from './GenerationCard'; import { GenerationCard } from './GenerationCard';
import { Toast } from './Toast'; import { Toast } from './Toast';
import { UserInfoBar } from './UserInfoBar'; import { VideoDetailModal } from './VideoDetailModal';
import { useGenerationStore } from '../store/generation'; import { useGenerationStore } from '../store/generation';
import type { GenerationTask } from '../types';
import styles from './VideoGenerationPage.module.css'; import styles from './VideoGenerationPage.module.css';
export function VideoGenerationPage() { export function VideoGenerationPage() {
const tasks = useGenerationStore((s) => s.tasks); const tasks = useGenerationStore((s) => s.tasks);
const loadTasks = useGenerationStore((s) => s.loadTasks); const loadTasks = useGenerationStore((s) => s.loadTasks);
const reEdit = useGenerationStore((s) => s.reEdit);
const regenerate = useGenerationStore((s) => s.regenerate);
const removeTask = useGenerationStore((s) => s.removeTask);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const prevCountRef = useRef(tasks.length); const prevCountRef = useRef(tasks.length);
const [detailTask, setDetailTask] = useState<GenerationTask | null>(null);
// Load tasks from backend on mount (persist across page refresh) // Load tasks from backend on mount (persist across page refresh)
useEffect(() => { useEffect(() => {
@ -21,16 +26,36 @@ export function VideoGenerationPage() {
// Auto-scroll to top when new task is added // Auto-scroll to top when new task is added
useEffect(() => { useEffect(() => {
if (tasks.length > prevCountRef.current && scrollRef.current) { if (tasks.length > prevCountRef.current && scrollRef.current) {
scrollRef.current.scrollTo({ top: 0, behavior: 'smooth' }); scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
} }
prevCountRef.current = tasks.length; prevCountRef.current = tasks.length;
}, [tasks.length]); }, [tasks.length]);
const handleReEdit = (id: string) => {
reEdit(id);
setDetailTask(null);
};
const handleRegenerate = (id: string) => {
regenerate(id);
setDetailTask(null);
};
const handleDelete = (id: string) => {
removeTask(id);
setDetailTask(null);
};
const completedTasks = useMemo(
() => tasks.filter((t) => t.status === 'completed' && t.resultUrl),
[tasks],
);
const detailIdx = detailTask ? completedTasks.findIndex((t) => t.id === detailTask.id) : -1;
return ( return (
<div className={styles.layout}> <div className={styles.layout}>
<Sidebar /> <Sidebar />
<main className={styles.main}> <main className={styles.main}>
<UserInfoBar />
<div className={styles.contentArea} ref={scrollRef}> <div className={styles.contentArea} ref={scrollRef}>
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<div className={styles.emptyArea}> <div className={styles.emptyArea}>
@ -39,7 +64,11 @@ export function VideoGenerationPage() {
) : ( ) : (
<div className={styles.taskList}> <div className={styles.taskList}>
{tasks.map((task) => ( {tasks.map((task) => (
<GenerationCard key={task.id} task={task} /> <GenerationCard
key={task.id}
task={task}
onOpenDetail={setDetailTask}
/>
))} ))}
</div> </div>
)} )}
@ -47,6 +76,17 @@ export function VideoGenerationPage() {
<InputBar /> <InputBar />
</main> </main>
<Toast /> <Toast />
<VideoDetailModal
task={detailTask}
onClose={() => setDetailTask(null)}
onReEdit={handleReEdit}
onRegenerate={handleRegenerate}
onDelete={handleDelete}
hasPrev={detailIdx > 0}
hasNext={detailIdx >= 0 && detailIdx < completedTasks.length - 1}
onPrev={() => detailIdx > 0 && setDetailTask(completedTasks[detailIdx - 1])}
onNext={() => detailIdx < completedTasks.length - 1 && setDetailTask(completedTasks[detailIdx + 1])}
/>
</div> </div>
); );
} }

View File

@ -1,27 +1,27 @@
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap');
:root { :root {
--color-bg-page: #0a0a0f; --color-bg-page: #07070f;
--color-bg-input-bar: #16161e; --color-bg-input-bar: rgba(255, 255, 255, 0.06);
--color-border-input-bar: #2a2a38; --color-border-input-bar: rgba(255, 255, 255, 0.10);
--color-primary: #00b8e6; --color-primary: #6c63ff;
--color-text-primary: #ffffff; --color-text-primary: #f1f0ff;
--color-text-secondary: #8a8a9a; --color-text-secondary: #8b8ea8;
--color-text-disabled: #4a4a5a; --color-text-disabled: #4c4f6b;
--color-bg-hover: rgba(255, 255, 255, 0.06); --color-bg-hover: rgba(255, 255, 255, 0.08);
--color-bg-dropdown: #1e1e2a; --color-bg-dropdown: rgba(13, 13, 26, 0.92);
--color-bg-upload: rgba(255, 255, 255, 0.04); --color-bg-upload: rgba(255, 255, 255, 0.04);
--color-border-upload: #2a2a38; --color-border-upload: rgba(255, 255, 255, 0.08);
--color-btn-send-disabled: #3a3a4a; --color-btn-send-disabled: #3a3a4a;
--color-btn-send-active: #00b8e6; --color-btn-send-active: #6c63ff;
--color-sidebar-bg: #0e0e14; --color-sidebar-bg: rgba(7, 7, 15, 0.80);
/* Phase 3: Admin theme tokens */ /* Phase 3: Admin theme tokens */
--color-bg-sidebar: #111118; --color-bg-sidebar: rgba(7, 7, 15, 0.80);
--color-sidebar-active: rgba(255, 255, 255, 0.08); --color-sidebar-active: rgba(255, 255, 255, 0.08);
--color-sidebar-hover: rgba(255, 255, 255, 0.04); --color-sidebar-hover: rgba(255, 255, 255, 0.04);
--color-bg-card: #16161e; --color-bg-card: rgba(255, 255, 255, 0.06);
--color-border-card: #2a2a38; --color-border-card: rgba(255, 255, 255, 0.10);
--color-success: #00b894; --color-success: #00b894;
--color-danger: #e74c3c; --color-danger: #e74c3c;
--color-warning: #f39c12; --color-warning: #f39c12;
@ -61,13 +61,162 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
/* Scrollbar: Firefox */
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
*:hover {
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}
/* Scrollbar: Webkit — hidden by default, visible on hover */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 4px; width: 6px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--color-border-input-bar); background: transparent;
border-radius: 4px; border-radius: 3px;
transition: background 0.2s;
}
*:hover::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
}
*:hover::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
/*
LAYER 1: Aurora Gradient Background
*/
.aurora-bg {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
.aurora-bg::before,
.aurora-bg::after {
content: "";
position: absolute;
border-radius: 50%;
filter: blur(110px);
opacity: 0.4;
will-change: transform;
}
.aurora-bg::before {
width: 600px;
height: 600px;
top: -10%;
right: -5%;
background: radial-gradient(circle, rgba(108, 99, 255, 0.6) 0%, transparent 70%);
animation: aurora-drift-1 20s ease-in-out infinite alternate;
}
.aurora-bg::after {
width: 500px;
height: 500px;
bottom: -5%;
left: -5%;
background: radial-gradient(circle, rgba(59, 130, 246, 0.5) 0%, transparent 70%);
animation: aurora-drift-2 25s ease-in-out infinite alternate;
}
@keyframes aurora-drift-1 {
0% { transform: translate(0, 0) scale(1); }
33% { transform: translate(-15vw, 10vh) scale(1.1); }
66% { transform: translate(-5vw, 25vh) scale(0.95); }
100% { transform: translate(-20vw, 15vh) scale(1.05); }
}
@keyframes aurora-drift-2 {
0% { transform: translate(0, 0) scale(1); }
33% { transform: translate(10vw, -15vh) scale(1.15); }
66% { transform: translate(20vw, -5vh) scale(0.9); }
100% { transform: translate(15vw, -20vh) scale(1.1); }
}
.aurora-blob-3 {
position: absolute;
width: 400px;
height: 400px;
top: 40%;
left: 50%;
border-radius: 50%;
background: radial-gradient(circle, rgba(139, 92, 246, 0.35) 0%, transparent 70%);
filter: blur(100px);
opacity: 0.3;
will-change: transform;
animation: aurora-drift-3 30s ease-in-out infinite alternate;
pointer-events: none;
}
@keyframes aurora-drift-3 {
0% { transform: translate(-50%, -50%) scale(1); }
50% { transform: translate(-30%, -60%) scale(1.2); }
100% { transform: translate(-70%, -40%) scale(0.85); }
}
/*
LAYER 2: Noise Texture Overlay
*/
.noise-overlay {
position: fixed;
inset: 0;
z-index: 1;
pointer-events: none;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 256px 256px;
}
/*
LAYER 3: Mouse-tracking Glow
*/
.cursor-glow {
position: fixed;
inset: 0;
z-index: 1;
pointer-events: none;
background: radial-gradient(
600px circle at var(--mouse-x, 50%) var(--mouse-y, 50%),
rgba(108, 99, 255, 0.06) 0%,
transparent 60%
);
transition: opacity 0.3s ease;
}
/*
LAYER 4: Subtle grid pattern
*/
.grid-pattern {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
background-size: 64px 64px;
mask-image: radial-gradient(ellipse 80% 60% at 50% 50%, black 20%, transparent 100%);
-webkit-mask-image: radial-gradient(ellipse 80% 60% at 50% 50%, black 20%, transparent 100%);
}
/*
Reduced motion
*/
@media (prefers-reduced-motion: reduce) {
.aurora-bg::before,
.aurora-bg::after,
.aurora-blob-3 {
animation: none !important;
opacity: 0.2 !important;
}
} }

View File

@ -116,6 +116,9 @@ export const videoApi = {
getTaskStatus: (taskId: string) => getTaskStatus: (taskId: string) =>
api.get<BackendTask>(`/video/tasks/${taskId}`), api.get<BackendTask>(`/video/tasks/${taskId}`),
deleteTask: (taskId: string) =>
api.delete(`/video/tasks/${taskId}`),
}; };
// Admin APIs // Admin APIs

View File

@ -87,6 +87,12 @@
color: var(--color-text-primary); color: var(--color-text-primary);
} }
.navDivider {
height: 1px;
background: var(--color-border-card);
margin: 4px 12px;
}
.sidebarFooter { .sidebarFooter {
padding: 12px; padding: 12px;
border-top: 1px solid var(--color-border-card); border-top: 1px solid var(--color-border-card);
@ -95,26 +101,6 @@
gap: 8px; gap: 8px;
} }
.backBtn {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: transparent;
border: 1px solid var(--color-border-card);
border-radius: 8px;
color: var(--color-text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.backBtn:hover {
background: var(--color-sidebar-hover);
color: var(--color-text-primary);
}
.userInfo { .userInfo {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -29,7 +29,7 @@ export function AdminLayout() {
<svg viewBox="0 0 24 24" width="24" height="24" fill="var(--color-primary)"> <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"/> <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> </svg>
{!collapsed && <span className={styles.logoText}>Jimeng Admin</span>} {!collapsed && <span className={styles.logoText}>AirDrama Admin</span>}
</div> </div>
<button className={styles.collapseBtn} onClick={() => setCollapsed(!collapsed)}> <button className={styles.collapseBtn} onClick={() => setCollapsed(!collapsed)}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="var(--color-text-secondary)"> <svg viewBox="0 0 24 24" width="16" height="16" fill="var(--color-text-secondary)">
@ -43,6 +43,13 @@ export function AdminLayout() {
</div> </div>
<nav className={styles.nav}> <nav className={styles.nav}>
<button className={styles.navItem} onClick={() => navigate('/')} 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>
{!collapsed && <span></span>}
</button>
<div className={styles.navDivider} />
{navItems.map((item) => ( {navItems.map((item) => (
<NavLink <NavLink
key={item.path} key={item.path}
@ -60,12 +67,6 @@ export function AdminLayout() {
</nav> </nav>
<div className={styles.sidebarFooter}> <div className={styles.sidebarFooter}>
<button className={styles.backBtn} onClick={() => navigate('/')}>
<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>
{!collapsed && <span></span>}
</button>
<div className={styles.userInfo}> <div className={styles.userInfo}>
<div className={styles.userAvatar}>{user?.username.charAt(0).toUpperCase()}</div> <div className={styles.userAvatar}>{user?.username.charAt(0).toUpperCase()}</div>
{!collapsed && ( {!collapsed && (

View File

@ -0,0 +1,167 @@
.layout {
display: flex;
height: 100%;
position: relative;
z-index: 2;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Tab header */
.tabHeader {
padding: 20px 32px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.tabs {
display: flex;
gap: 24px;
margin-bottom: 12px;
}
.tab {
font-size: 16px;
font-weight: 500;
color: var(--color-text-secondary);
cursor: pointer;
padding-bottom: 8px;
border-bottom: 2px solid transparent;
transition: color 0.15s;
}
.tab:hover {
color: var(--color-text-primary);
}
.tabActive {
color: var(--color-text-primary);
border-bottom-color: var(--color-primary);
}
.subTabs {
display: flex;
gap: 16px;
}
.subTab {
font-size: 13px;
color: var(--color-text-secondary);
cursor: pointer;
padding-bottom: 10px;
border-bottom: 2px solid transparent;
transition: color 0.15s;
}
.subTab:hover {
color: var(--color-text-primary);
}
.subTabActive {
color: var(--color-text-primary);
border-bottom-color: var(--color-primary);
}
/* Content */
.content {
flex: 1;
overflow-y: auto;
padding: 24px 32px;
}
.empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--color-text-disabled);
font-size: 14px;
}
/* Date group */
.dateGroup {
margin-bottom: 32px;
}
.dateLabel {
font-size: 14px;
font-weight: 500;
color: var(--color-text-secondary);
margin-bottom: 16px;
}
/* Thumbnail grid: 5 columns */
.grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
@media (max-width: 1200px) {
.grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (max-width: 900px) {
.grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 600px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* Thumbnail card */
.thumbnail {
position: relative;
aspect-ratio: 16 / 9;
border-radius: 10px;
overflow: hidden;
background: rgba(0, 0, 0, 0.3);
cursor: pointer;
transition: transform 0.15s;
}
.thumbnail:hover {
transform: scale(1.02);
}
.thumbVideo {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.thumbPlaceholder {
width: 100%;
height: 100%;
background: #1a1a24;
}
.durationBadge {
position: absolute;
bottom: 6px;
left: 6px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.thumbOverlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.15);
pointer-events: none;
}

View File

@ -0,0 +1,176 @@
import { useEffect, useState, useRef, useMemo } from 'react';
import { Sidebar } from '../components/Sidebar';
import { VideoDetailModal } from '../components/VideoDetailModal';
import { useGenerationStore } from '../store/generation';
import type { GenerationTask } from '../types';
import styles from './AssetsPage.module.css';
function groupByDate(tasks: GenerationTask[]): { label: string; tasks: GenerationTask[] }[] {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const yesterday = today - 86400000;
const groups = new Map<string, GenerationTask[]>();
const order: string[] = [];
for (const task of tasks) {
const d = new Date(task.createdAt);
const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
let label: string;
if (dayStart >= today) {
label = '今天';
} else if (dayStart >= yesterday) {
label = '昨天';
} else {
label = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
if (!groups.has(label)) {
groups.set(label, []);
order.push(label);
}
groups.get(label)!.push(task);
}
return order.map((label) => ({ label, tasks: groups.get(label)! }));
}
function VideoThumbnail({
task,
onClick,
}: {
task: GenerationTask;
onClick: () => void;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const [hover, setHover] = useState(false);
const handleEnter = () => {
setHover(true);
videoRef.current?.play().catch(() => {});
};
const handleLeave = () => {
setHover(false);
if (videoRef.current) {
videoRef.current.pause();
videoRef.current.currentTime = 0;
}
};
const durationLabel = `00:${String(task.duration).padStart(2, '0')}`;
return (
<div
className={styles.thumbnail}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
onClick={onClick}
>
{task.resultUrl ? (
<video
ref={videoRef}
src={task.resultUrl}
className={styles.thumbVideo}
muted
loop
preload="metadata"
/>
) : (
<div className={styles.thumbPlaceholder} />
)}
<span className={styles.durationBadge}>{durationLabel}</span>
{hover && <div className={styles.thumbOverlay} />}
</div>
);
}
export function AssetsPage() {
const tasks = useGenerationStore((s) => s.tasks);
const loadTasks = useGenerationStore((s) => s.loadTasks);
const reEdit = useGenerationStore((s) => s.reEdit);
const regenerate = useGenerationStore((s) => s.regenerate);
const removeTask = useGenerationStore((s) => s.removeTask);
const [detailTask, setDetailTask] = useState<GenerationTask | null>(null);
useEffect(() => {
loadTasks();
}, [loadTasks]);
const completedTasks = useMemo(
() => tasks.filter((t) => t.status === 'completed'),
[tasks],
);
const dateGroups = useMemo(() => groupByDate(completedTasks), [completedTasks]);
const handleReEdit = (id: string) => {
reEdit(id);
setDetailTask(null);
};
const handleRegenerate = (id: string) => {
regenerate(id);
setDetailTask(null);
};
const handleDelete = (id: string) => {
removeTask(id);
setDetailTask(null);
};
const detailIdx = detailTask ? completedTasks.findIndex((t) => t.id === detailTask.id) : -1;
return (
<div className={styles.layout}>
<Sidebar />
<main className={styles.main}>
{/* Tab header */}
<div className={styles.tabHeader}>
<div className={styles.tabs}>
<span className={`${styles.tab} ${styles.tabActive}`}></span>
</div>
<div className={styles.subTabs}>
<span className={`${styles.subTab} ${styles.subTabActive}`}></span>
<span className={styles.subTab}></span>
</div>
</div>
{/* Video grid by date */}
<div className={styles.content}>
{completedTasks.length === 0 ? (
<div className={styles.empty}>
<p></p>
</div>
) : (
dateGroups.map((group) => (
<section key={group.label} className={styles.dateGroup}>
<h3 className={styles.dateLabel}>{group.label}</h3>
<div className={styles.grid}>
{group.tasks.map((task) => (
<VideoThumbnail
key={task.id}
task={task}
onClick={() => setDetailTask(task)}
/>
))}
</div>
</section>
))
)}
</div>
</main>
<VideoDetailModal
task={detailTask}
onClose={() => setDetailTask(null)}
onReEdit={handleReEdit}
onRegenerate={handleRegenerate}
onDelete={handleDelete}
hasPrev={detailIdx > 0}
hasNext={detailIdx >= 0 && detailIdx < completedTasks.length - 1}
onPrev={() => detailIdx > 0 && setDetailTask(completedTasks[detailIdx - 1])}
onNext={() => detailIdx < completedTasks.length - 1 && setDetailTask(completedTasks[detailIdx + 1])}
/>
</div>
);
}

View File

@ -5,6 +5,8 @@
justify-content: center; justify-content: center;
background: var(--color-bg-page); background: var(--color-bg-page);
padding: 20px; padding: 20px;
position: relative;
z-index: 2;
} }
.card { .card {

View File

@ -54,9 +54,9 @@ export function DashboardPage() {
const trendOption: echarts.EChartsCoreOption = { const trendOption: echarts.EChartsCoreOption = {
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
backgroundColor: '#1e1e2a', backgroundColor: 'rgba(13, 13, 26, 0.95)',
borderColor: '#2a2a38', borderColor: 'rgba(255, 255, 255, 0.10)',
textStyle: { color: '#e2e8f0', fontSize: 12 }, textStyle: { color: '#f1f0ff', fontSize: 12 },
formatter: (params: unknown) => { formatter: (params: unknown) => {
const p = (params as { name: string; value: number }[])[0]; const p = (params as { name: string; value: number }[])[0];
return `${p.name}<br/>消费: ${p.value}s`; return `${p.name}<br/>消费: ${p.value}s`;
@ -66,27 +66,27 @@ export function DashboardPage() {
xAxis: { xAxis: {
type: 'category', type: 'category',
data: stats.daily_trend.map((d) => d.date.slice(5)), data: stats.daily_trend.map((d) => d.date.slice(5)),
axisLabel: { color: '#8a8a9a', fontSize: 11 }, axisLabel: { color: '#8b8ea8', fontSize: 11 },
axisLine: { lineStyle: { color: '#2a2a38' } }, axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } },
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
axisLabel: { color: '#8a8a9a', fontSize: 11 }, axisLabel: { color: '#8b8ea8', fontSize: 11 },
splitLine: { lineStyle: { color: '#1e1e2a' } }, splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } },
}, },
dataZoom: [{ type: 'inside', start: 0, end: 100 }], dataZoom: [{ type: 'inside', start: 0, end: 100 }],
series: [{ series: [{
type: 'line', type: 'line',
data: stats.daily_trend.map((d) => d.seconds), data: stats.daily_trend.map((d) => d.seconds),
smooth: true, smooth: true,
lineStyle: { color: '#00b8e6', width: 2 }, lineStyle: { color: '#6c63ff', width: 2 },
areaStyle: { areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(0, 184, 230, 0.25)' }, { offset: 0, color: 'rgba(108, 99, 255, 0.25)' },
{ offset: 1, color: 'rgba(0, 184, 230, 0.02)' }, { offset: 1, color: 'rgba(108, 99, 255, 0.02)' },
]), ]),
}, },
itemStyle: { color: '#00b8e6' }, itemStyle: { color: '#6c63ff' },
}], }],
}; };
@ -95,21 +95,21 @@ export function DashboardPage() {
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
axisPointer: { type: 'shadow' }, axisPointer: { type: 'shadow' },
backgroundColor: '#1e1e2a', backgroundColor: 'rgba(13, 13, 26, 0.95)',
borderColor: '#2a2a38', borderColor: 'rgba(255, 255, 255, 0.10)',
textStyle: { color: '#e2e8f0', fontSize: 12 }, textStyle: { color: '#f1f0ff', fontSize: 12 },
}, },
grid: { left: 80, right: 40, top: 10, bottom: 20 }, grid: { left: 80, right: 40, top: 10, bottom: 20 },
xAxis: { xAxis: {
type: 'value', type: 'value',
axisLabel: { color: '#8a8a9a', fontSize: 11 }, axisLabel: { color: '#8b8ea8', fontSize: 11 },
splitLine: { lineStyle: { color: '#1e1e2a' } }, splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.06)' } },
}, },
yAxis: { yAxis: {
type: 'category', type: 'category',
data: sortedUsers.map((u) => u.username), data: sortedUsers.map((u) => u.username),
axisLabel: { color: '#8a8a9a', fontSize: 12 }, axisLabel: { color: '#8b8ea8', fontSize: 12 },
axisLine: { lineStyle: { color: '#2a2a38' } }, axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.08)' } },
}, },
series: [{ series: [{
type: 'bar', type: 'bar',
@ -117,15 +117,15 @@ export function DashboardPage() {
barWidth: 16, barWidth: 16,
itemStyle: { itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [ color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#00b8e6' }, { offset: 0, color: '#6c63ff' },
{ offset: 1, color: '#7c3aed' }, { offset: 1, color: '#8b5cf6' },
]), ]),
borderRadius: [0, 4, 4, 0], borderRadius: [0, 4, 4, 0],
}, },
label: { label: {
show: true, show: true,
position: 'right', position: 'right',
color: '#8a8a9a', color: '#8b8ea8',
fontSize: 11, fontSize: 11,
formatter: '{c}s', formatter: '{c}s',
}, },

View File

@ -33,7 +33,7 @@ export function LoginPage() {
return ( return (
<div className={styles.page}> <div className={styles.page}>
<div className={styles.card}> <div className={styles.card}>
<h1 className={styles.title}>Jimeng Clone</h1> <h1 className={styles.title}>AirDrama</h1>
<p className={styles.subtitle}>AI </p> <p className={styles.subtitle}>AI </p>
<form onSubmit={handleSubmit} className={styles.form}> <form onSubmit={handleSubmit} className={styles.form}>

View File

@ -1,9 +1,8 @@
.page { .page {
max-width: 720px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 24px 20px 60px; padding: 24px 20px 60px;
height: 100vh; min-height: 100vh;
overflow-y: auto;
} }
/* Header */ /* Header */
@ -105,28 +104,11 @@
/* Overview grid */ /* Overview grid */
.overviewGrid { .overviewGrid {
display: grid; display: grid;
grid-template-columns: 180px 1fr 1fr; grid-template-columns: 1fr 1fr 1fr;
gap: 14px; gap: 14px;
align-items: stretch; align-items: stretch;
} }
.gaugeCard {
background: var(--color-bg-card);
border: 1px solid var(--color-border-card);
border-radius: var(--radius-card);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 12px;
}
.gaugeLabel {
color: var(--color-text-secondary);
font-size: 12px;
margin-top: 4px;
}
.quotaCard { .quotaCard {
background: var(--color-bg-card); background: var(--color-bg-card);
border: 1px solid var(--color-border-card); border: 1px solid var(--color-border-card);
@ -356,11 +338,17 @@
.skeletonCards { .skeletonCards {
display: grid; display: grid;
grid-template-columns: 180px 1fr 1fr; grid-template-columns: 1fr 1fr 1fr;
gap: 14px; gap: 14px;
height: 180px; height: 180px;
} }
@media (max-width: 900px) {
.skeletonCards {
grid-template-columns: 1fr 1fr;
}
}
.skeletonCards::before, .skeletonCards::before,
.skeletonCards::after { .skeletonCards::after {
content: ''; content: '';
@ -381,6 +369,12 @@
50% { opacity: 0.5; } 50% { opacity: 0.5; }
} }
@media (max-width: 900px) {
.overviewGrid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 640px) { @media (max-width: 640px) {
.overviewGrid { .overviewGrid {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import ReactEChartsCore from 'echarts-for-react/lib/core'; import ReactEChartsCore from 'echarts-for-react/lib/core';
import * as echarts from 'echarts/core'; import * as echarts from 'echarts/core';
import { GaugeChart, LineChart } from 'echarts/charts'; import { LineChart } from 'echarts/charts';
import { GridComponent, TooltipComponent } from 'echarts/components'; import { GridComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers'; import { CanvasRenderer } from 'echarts/renderers';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
@ -11,7 +11,7 @@ import type { ProfileOverview, AdminRecord } from '../types';
import { showToast } from '../components/Toast'; import { showToast } from '../components/Toast';
import styles from './ProfilePage.module.css'; import styles from './ProfilePage.module.css';
echarts.use([GaugeChart, LineChart, GridComponent, TooltipComponent, CanvasRenderer]); echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]);
export function ProfilePage() { export function ProfilePage() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
@ -75,37 +75,8 @@ export function ProfilePage() {
const dailyPercent = overview.daily_seconds_limit > 0 ? (overview.daily_seconds_used / overview.daily_seconds_limit) * 100 : 0; const dailyPercent = overview.daily_seconds_limit > 0 ? (overview.daily_seconds_used / overview.daily_seconds_limit) * 100 : 0;
const monthlyPercent = overview.monthly_seconds_limit > 0 ? (overview.monthly_seconds_used / overview.monthly_seconds_limit) * 100 : 0; const monthlyPercent = overview.monthly_seconds_limit > 0 ? (overview.monthly_seconds_used / overview.monthly_seconds_limit) * 100 : 0;
const gaugeOption: echarts.EChartsCoreOption = { const totalRemaining = Math.max(0, overview.monthly_seconds_limit - overview.total_seconds_used);
series: [{ const totalPercent = overview.monthly_seconds_limit > 0 ? (overview.total_seconds_used / overview.monthly_seconds_limit) * 100 : 0;
type: 'gauge',
startAngle: 220,
endAngle: -40,
min: 0,
max: overview.daily_seconds_limit,
pointer: { show: false },
progress: {
show: true,
width: 14,
roundCap: true,
itemStyle: {
color: dailyPercent > 80 ? (dailyPercent >= 100 ? '#e74c3c' : '#f39c12') : '#00b8e6',
},
},
axisLine: { lineStyle: { width: 14, color: [[1, '#1e1e2a']] } },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
detail: {
valueAnimation: true,
fontSize: 20,
fontWeight: 700,
color: '#fff',
formatter: `${overview.daily_seconds_used}s\n/${overview.daily_seconds_limit}s`,
offsetCenter: [0, '10%'],
},
data: [{ value: overview.daily_seconds_used }],
}],
};
const sparklineOption: echarts.EChartsCoreOption = { const sparklineOption: echarts.EChartsCoreOption = {
tooltip: { tooltip: {
@ -162,9 +133,16 @@ export function ProfilePage() {
<div className={styles.overviewSection}> <div className={styles.overviewSection}>
<h2 className={styles.sectionTitle}></h2> <h2 className={styles.sectionTitle}></h2>
<div className={styles.overviewGrid}> <div className={styles.overviewGrid}>
<div className={styles.gaugeCard}> <div className={styles.quotaCard}>
<ReactEChartsCore echarts={echarts} option={gaugeOption} style={{ height: 180, width: 180 }} /> <div className={styles.quotaLabel}></div>
<div className={styles.gaugeLabel}></div> <div className={styles.quotaValue}>: {overview.total_seconds_used}s / {overview.monthly_seconds_limit}s</div>
<div className={styles.progressBar}>
<div className={styles.progressFill} style={{
width: `${Math.min(totalPercent, 100)}%`,
background: totalPercent > 80 ? (totalPercent >= 100 ? 'var(--color-danger)' : 'var(--color-warning)') : 'var(--color-primary)',
}} />
</div>
<div className={styles.quotaPercent}> {totalRemaining}s</div>
</div> </div>
<div className={styles.quotaCard}> <div className={styles.quotaCard}>
<div className={styles.quotaLabel}></div> <div className={styles.quotaLabel}></div>

View File

@ -5,6 +5,42 @@ import { videoApi, mediaApi } from '../lib/api';
import { useAuthStore } from './auth'; import { useAuthStore } from './auth';
import { showToast } from '../components/Toast'; import { showToast } from '../components/Toast';
// Map raw API error messages to user-friendly Chinese
function mapErrorMessage(raw?: string): string | undefined {
if (!raw) return undefined;
const s = raw.toLowerCase();
// HTTP 4xx
if (s.includes('400') || s.includes('bad request') || s.includes('invalid'))
return '请求参数有误,请检查输入内容';
if (s.includes('401') || s.includes('403') || s.includes('unauthorized') || s.includes('forbidden'))
return '服务认证失败,请联系管理员';
if (s.includes('429') || s.includes('too many') || s.includes('rate limit'))
return '请求过于频繁,请稍后再试';
// HTTP 5xx / server errors
if (s.includes('500') || s.includes('502') || s.includes('503') || s.includes('internal server') || s.includes('bad gateway') || s.includes('service unavailable'))
return '服务器繁忙,请稍后重试';
// Timeout
if (s.includes('timeout') || s.includes('timed out'))
return '请求超时,请重试';
// Connection errors
if (s.includes('connection') || s.includes('network') || s.includes('econnrefused'))
return '网络连接失败,请检查网络后重试';
// Model / generation errors
if (s.includes('quota') || s.includes('insufficient'))
return '额度不足,请联系管理员';
// If already Chinese, return as-is
if (/[\u4e00-\u9fa5]/.test(raw)) return raw;
// Fallback
return '生成失败,请重试';
}
// Map backend status to frontend TaskStatus // Map backend status to frontend TaskStatus
function mapStatus(backendStatus: string): 'generating' | 'completed' | 'failed' { function mapStatus(backendStatus: string): 'generating' | 'completed' | 'failed' {
if (backendStatus === 'completed' || backendStatus === 'succeeded') return 'completed'; if (backendStatus === 'completed' || backendStatus === 'succeeded') return 'completed';
@ -41,7 +77,7 @@ function backendToFrontend(bt: BackendTask): GenerationTask {
status: mapStatus(bt.status), status: mapStatus(bt.status),
progress: mapProgress(bt.status), progress: mapProgress(bt.status),
resultUrl: bt.result_url || undefined, resultUrl: bt.result_url || undefined,
errorMessage: bt.error_message || undefined, errorMessage: mapErrorMessage(bt.error_message),
createdAt: new Date(bt.created_at).getTime(), createdAt: new Date(bt.created_at).getTime(),
}; };
} }
@ -65,7 +101,7 @@ function startPolling(taskId: string, frontendId: string) {
status: newStatus, status: newStatus,
progress: newStatus === 'completed' ? 100 : newStatus === 'failed' ? 0 : Math.min(t.progress + 5, 90), progress: newStatus === 'completed' ? 100 : newStatus === 'failed' ? 0 : Math.min(t.progress + 5, 90),
resultUrl: data.result_url || t.resultUrl, resultUrl: data.result_url || t.resultUrl,
errorMessage: data.error_message || t.errorMessage, errorMessage: mapErrorMessage(data.error_message) || t.errorMessage,
} }
: t : t
), ),
@ -110,9 +146,132 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
loadTasks: async () => { loadTasks: async () => {
set({ isLoading: true }); set({ isLoading: true });
let tasks: GenerationTask[] = [];
try { try {
const { data } = await videoApi.getTasks(); const { data } = await videoApi.getTasks();
const tasks = data.results.map(backendToFrontend); tasks = data.results.map(backendToFrontend).reverse();
} catch {
// API unavailable — tasks stays empty, mocks will fill in below
}
// Dev-only mock tasks for previewing all card states
if (import.meta.env.DEV) {
tasks.push(
// ① 已完成 — 16:9 城市航拍
{
id: 'mock_completed_169',
taskId: 'demo-169',
prompt: '航拍镜头从城市上空缓缓下降,金色夕阳照亮整个天际线,镜头缓慢推进穿过云层',
editorHtml: '航拍镜头从城市上空缓缓下降,金色夕阳照亮整个天际线,镜头缓慢推进穿过云层',
mode: 'universal' as const,
model: 'seedance_2.0' as const,
aspectRatio: '16:9' as const,
duration: 10 as const,
references: [],
status: 'completed' as const,
progress: 100,
resultUrl: '/demo/demo-16-9.mp4',
createdAt: Date.now() - 3600000,
},
// ② 已完成 — 21:9 爆炸场景
{
id: 'mock_completed_219',
taskId: 'demo-219',
prompt: '0-3s手持近景镜头 + 轻微晃动 + 缓慢推近,爆炸后的烟尘缓缓落下,环境沉闷压抑',
editorHtml: '0-3s手持近景镜头 + 轻微晃动 + 缓慢推近,爆炸后的烟尘缓缓落下',
mode: 'universal' as const,
model: 'seedance_2.0' as const,
aspectRatio: '21:9' as const,
duration: 15 as const,
references: [],
status: 'completed' as const,
progress: 100,
resultUrl: '/demo/demo-21-9.mp4',
createdAt: Date.now() - 7200000,
},
// ③ 已完成 — 9:16 人物出场
{
id: 'mock_completed_916',
taskId: 'demo-916',
prompt: '出场人物张磊、队员1-8紧张的救援场面烟雾弥漫中队员们有序前进',
editorHtml: '出场人物张磊、队员1-8紧张的救援场面烟雾弥漫中队员们有序前进',
mode: 'universal' as const,
model: 'seedance_2.0_fast' as const,
aspectRatio: '9:16' as const,
duration: 4 as const,
references: [],
status: 'completed' as const,
progress: 100,
resultUrl: '/demo/demo-9-16.mp4',
createdAt: Date.now() - 6000000,
},
// ④ 生成中 — 刚开始 (5%)
{
id: 'mock_generating_low',
taskId: 'demo-gen-low',
prompt: '微距镜头拍摄雨滴落在花瓣上的慢动作,水珠在花瓣表面缓缓滑落',
editorHtml: '微距镜头拍摄雨滴落在花瓣上的慢动作,水珠在花瓣表面缓缓滑落',
mode: 'universal' as const,
model: 'seedance_2.0' as const,
aspectRatio: '16:9' as const,
duration: 10 as const,
references: [],
status: 'generating' as const,
progress: 5,
createdAt: Date.now() - 60000,
},
// ⑤ 生成中 — 进行中 (60%)
{
id: 'mock_generating_mid',
taskId: 'demo-gen-mid',
prompt: '水墨风格的山水画卷缓缓展开,远处群山叠嶂,近处溪流潺潺',
editorHtml: '水墨风格的山水画卷缓缓展开,远处群山叠嶂,近处溪流潺潺',
mode: 'universal' as const,
model: 'seedance_2.0_fast' as const,
aspectRatio: '1:1' as const,
duration: 5 as const,
references: [],
status: 'generating' as const,
progress: 60,
createdAt: Date.now() - 120000,
},
// ⑥ 失败 — 参数错误
{
id: 'mock_failed_param',
taskId: 'demo-fail-param',
prompt: '深海探索镜头,潜水艇灯光照亮周围的珊瑚礁和鱼群',
editorHtml: '深海探索镜头,潜水艇灯光照亮周围的珊瑚礁和鱼群',
mode: 'universal' as const,
model: 'seedance_2.0' as const,
aspectRatio: '16:9' as const,
duration: 10 as const,
references: [],
status: 'failed' as const,
progress: 0,
errorMessage: '请求参数有误,请检查输入内容',
createdAt: Date.now() - 5000000,
},
// ⑦ 失败 — 服务器错误
{
id: 'mock_failed_server',
taskId: 'demo-fail-server',
prompt: '星空延时摄影,银河缓缓转动,前景是雪山湖泊的倒影',
editorHtml: '星空延时摄影,银河缓缓转动,前景是雪山湖泊的倒影',
mode: 'universal' as const,
model: 'seedance_2.0' as const,
aspectRatio: '4:3' as const,
duration: 8 as const,
references: [],
status: 'failed' as const,
progress: 0,
errorMessage: '服务器繁忙,请稍后重试',
createdAt: Date.now() - 4000000,
},
);
}
set({ tasks, isLoading: false }); set({ tasks, isLoading: false });
// Start polling for any active tasks // Start polling for any active tasks
@ -121,9 +280,6 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
startPolling(task.taskId, task.id); startPolling(task.taskId, task.id);
} }
} }
} catch {
set({ isLoading: false });
}
}, },
addTask: async () => { addTask: async () => {
@ -131,15 +287,17 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
if (!input.canSubmit()) return null; if (!input.canSubmit()) return null;
// Collect files to upload (or existing TOS URLs for regeneration) // Collect files to upload (or existing TOS URLs for regeneration)
const filesToUpload: { file?: File; tosUrl?: string; type: 'image' | 'video'; role: string; label: string }[] = []; const filesToUpload: { file?: File; tosUrl?: string; type: 'image' | 'video' | 'audio'; role: string; label: string }[] = [];
const getRoleForType = (type: 'image' | 'video' | 'audio') =>
type === 'video' ? 'reference_video' : type === 'audio' ? 'reference_audio' : 'reference_image';
if (input.mode === 'universal') { if (input.mode === 'universal') {
for (const ref of input.references) { for (const ref of input.references) {
if (ref.tosUrl) { if (ref.tosUrl) {
// Already uploaded to TOS (regeneration) filesToUpload.push({ tosUrl: ref.tosUrl, type: ref.type, role: getRoleForType(ref.type), label: ref.label });
filesToUpload.push({ tosUrl: ref.tosUrl, type: ref.type, role: ref.type === 'video' ? 'reference_video' : 'reference_image', label: ref.label });
} else if (ref.file) { } else if (ref.file) {
filesToUpload.push({ file: ref.file, type: ref.type, role: ref.type === 'video' ? 'reference_video' : 'reference_image', label: ref.label }); filesToUpload.push({ file: ref.file, type: ref.type, role: getRoleForType(ref.type), label: ref.label });
} }
} }
} else { } else {
@ -196,7 +354,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
createdAt: Date.now(), createdAt: Date.now(),
}; };
set((s) => ({ tasks: [placeholderTask, ...s.tasks] })); set((s) => ({ tasks: [...s.tasks, placeholderTask] }));
// Clear input // Clear input
useInputBarStore.setState({ useInputBarStore.setState({
@ -301,7 +459,11 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
removeTask: (id) => { removeTask: (id) => {
stopPolling(id); stopPolling(id);
const task = get().tasks.find((t) => t.id === id);
set((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) })); set((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) }));
if (task?.taskId) {
videoApi.deleteTask(task.taskId).catch(() => {});
}
}, },
reEdit: (id) => { reEdit: (id) => {

View File

@ -1,8 +1,14 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { CreationMode, ModelOption, AspectRatio, Duration, GenerationType, UploadedFile } from '../types'; import type { CreationMode, ModelOption, AspectRatio, Duration, GenerationType, UploadedFile } from '../types';
import { showToast } from '../components/Toast';
let fileCounter = 0; let fileCounter = 0;
// API limits per Seedance 2.0 official doc
const MAX_IMAGES = 9;
const MAX_VIDEOS = 3;
const MAX_AUDIO = 3;
interface InputBarState { interface InputBarState {
// Generation type // Generation type
generationType: GenerationType; generationType: GenerationType;
@ -36,6 +42,7 @@ interface InputBarState {
// Universal references // Universal references
references: UploadedFile[]; references: UploadedFile[];
prevReferences: UploadedFile[];
addReferences: (files: File[]) => void; addReferences: (files: File[]) => void;
removeReference: (id: string) => void; removeReference: (id: string) => void;
clearReferences: () => void; clearReferences: () => void;
@ -91,24 +98,42 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
setEditorHtml: (editorHtml) => set({ editorHtml }), setEditorHtml: (editorHtml) => set({ editorHtml }),
references: [], references: [],
prevReferences: [],
addReferences: (files) => { addReferences: (files) => {
const state = get(); const state = get();
const remaining = 5 - state.references.length; // Count existing references by type
if (remaining <= 0) return; const counts = { image: 0, video: 0, audio: 0 };
const toAdd = files.slice(0, remaining); for (const ref of state.references) counts[ref.type]++;
const newRefs: UploadedFile[] = toAdd.map((file) => {
const newRefs: UploadedFile[] = [];
for (const file of files) {
const type: 'image' | 'video' | 'audio' = file.type.startsWith('video/')
? 'video'
: file.type.startsWith('audio/')
? 'audio'
: 'image';
const max = type === 'image' ? MAX_IMAGES : type === 'video' ? MAX_VIDEOS : MAX_AUDIO;
if (counts[type] >= max) {
const label = type === 'image' ? `最多上传${MAX_IMAGES}张图片` : type === 'video' ? `最多上传${MAX_VIDEOS}个视频` : `最多上传${MAX_AUDIO}个音频`;
showToast(label);
continue;
}
fileCounter++; fileCounter++;
const type = file.type.startsWith('video') ? 'video' as const : 'image' as const; const labelPrefix = type === 'video' ? '视频' : type === 'audio' ? '音频' : '图片';
const labelPrefix = type === 'video' ? '视频' : '图片'; newRefs.push({
return {
id: `ref_${fileCounter}`, id: `ref_${fileCounter}`,
file, file,
type, type,
previewUrl: URL.createObjectURL(file), previewUrl: type === 'audio' ? '' : URL.createObjectURL(file),
label: `${labelPrefix}${fileCounter}`, label: `${labelPrefix}${fileCounter}`,
};
}); });
counts[type]++;
}
if (newRefs.length > 0) {
set({ references: [...state.references, ...newRefs] }); set({ references: [...state.references, ...newRefs] });
}
}, },
removeReference: (id) => { removeReference: (id) => {
const state = get(); const state = get();
@ -168,7 +193,13 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
state.mode === 'universal' state.mode === 'universal'
? state.references.length > 0 ? state.references.length > 0
: state.firstFrame !== null || state.lastFrame !== null; : state.firstFrame !== null || state.lastFrame !== null;
return hasText || hasFiles; if (!hasText && !hasFiles) return false;
// Audio cannot be sent alone — must have image or video
if (state.mode === 'universal' && state.references.length > 0) {
const hasImageOrVideo = state.references.some((r) => r.type === 'image' || r.type === 'video');
if (!hasImageOrVideo && !hasText) return false;
}
return true;
}, },
insertAtTrigger: 0, insertAtTrigger: 0,
@ -179,10 +210,9 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
if (state.mode === mode) return; if (state.mode === mode) return;
if (mode === 'keyframe') { if (mode === 'keyframe') {
// Clear universal references
state.references.forEach((r) => URL.revokeObjectURL(r.previewUrl));
set({ set({
mode, mode,
prevReferences: state.references,
references: [], references: [],
duration: 5, duration: 5,
}); });
@ -194,6 +224,8 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
mode, mode,
firstFrame: null, firstFrame: null,
lastFrame: null, lastFrame: null,
references: state.prevReferences,
prevReferences: [],
aspectRatio: state.prevAspectRatio, aspectRatio: state.prevAspectRatio,
duration: state.prevDuration, duration: state.prevDuration,
}); });
@ -207,6 +239,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
reset: () => { reset: () => {
const state = get(); const state = get();
state.references.forEach((r) => URL.revokeObjectURL(r.previewUrl)); state.references.forEach((r) => URL.revokeObjectURL(r.previewUrl));
state.prevReferences.forEach((r) => URL.revokeObjectURL(r.previewUrl));
if (state.firstFrame) URL.revokeObjectURL(state.firstFrame.previewUrl); if (state.firstFrame) URL.revokeObjectURL(state.firstFrame.previewUrl);
if (state.lastFrame) URL.revokeObjectURL(state.lastFrame.previewUrl); if (state.lastFrame) URL.revokeObjectURL(state.lastFrame.previewUrl);
set({ set({
@ -219,6 +252,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
prompt: '', prompt: '',
editorHtml: '', editorHtml: '',
references: [], references: [],
prevReferences: [],
firstFrame: null, firstFrame: null,
lastFrame: null, lastFrame: null,
generationType: 'video', generationType: 'video',

View File

@ -7,7 +7,7 @@ export type GenerationType = 'video' | 'image';
export interface UploadedFile { export interface UploadedFile {
id: string; id: string;
file?: File; file?: File;
type: 'image' | 'video'; type: 'image' | 'video' | 'audio';
previewUrl: string; previewUrl: string;
label: string; label: string;
tosUrl?: string; // TOS URL after upload tosUrl?: string; // TOS URL after upload
@ -24,7 +24,7 @@ export type TaskStatus = 'generating' | 'completed' | 'failed';
export interface ReferenceSnapshot { export interface ReferenceSnapshot {
id: string; id: string;
type: 'image' | 'video'; type: 'image' | 'video' | 'audio';
previewUrl: string; previewUrl: string;
label: string; label: string;
role?: string; role?: string;