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
ALLOWED_IMAGE_EXTS = {'jpeg', 'jpg', 'png', 'webp', 'bmp', 'tiff', 'gif'}
ALLOWED_VIDEO_EXTS = {'mp4', 'mov'}
ALLOWED_AUDIO_EXTS = {'mp3', 'wav'}
MAX_IMAGE_SIZE = 30 * 1024 * 1024 # 30MB
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.
_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:
media_type = 'video'
max_size = MAX_VIDEO_SIZE
elif ext in ALLOWED_AUDIO_EXTS:
media_type = 'audio'
max_size = MAX_AUDIO_SIZE
else:
return Response(
{'error': f'不支持的文件格式: {ext}'},
@ -276,10 +281,11 @@ def video_tasks_list_view(request):
return Response({'results': results})
@api_view(['GET'])
@api_view(['GET', 'DELETE'])
@permission_classes([IsAuthenticated])
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:
record = _eval_qs(
GenerationRecord.objects.filter(user=request.user),
@ -288,6 +294,10 @@ def video_task_detail_view(request, task_id):
except GenerationRecord.DoesNotExist:
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
ark_task_id = record.__dict__.get('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
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
from pathlib import Path
@ -8,7 +8,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get(
'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')

View File

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

View File

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

24
web/package-lock.json generated
View File

@ -170,6 +170,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@ -531,6 +532,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@ -571,6 +573,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@ -1560,8 +1563,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@ -1653,6 +1655,7 @@
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@ -1664,6 +1667,7 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@ -1839,7 +1843,6 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@ -1850,7 +1853,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@ -1950,6 +1952,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -2209,8 +2212,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/dom-helpers": {
"version": "5.2.1",
@ -2680,6 +2682,7 @@
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.31",
"@asamuzakjp/dom-selector": "^6.8.1",
@ -2775,7 +2778,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@ -2929,6 +2931,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -3018,7 +3021,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@ -3033,8 +3035,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
@ -3074,6 +3075,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@ -3098,6 +3100,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@ -3593,6 +3596,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"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 { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AmbientBackground } from './components/AmbientBackground';
import { VideoGenerationPage } from './components/VideoGenerationPage';
import { ProtectedRoute } from './components/ProtectedRoute';
import { LoginPage } from './pages/LoginPage';
@ -10,6 +11,7 @@ import { UsersPage } from './pages/UsersPage';
import { RecordsPage } from './pages/RecordsPage';
import { SettingsPage } from './pages/SettingsPage';
import { ProfilePage } from './pages/ProfilePage';
import { AssetsPage } from './pages/AssetsPage';
import { useAuthStore } from './store/auth';
export default function App() {
@ -21,6 +23,7 @@ export default function App() {
return (
<BrowserRouter>
<AmbientBackground />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
@ -31,6 +34,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/assets"
element={
<ProtectedRoute>
<AssetsPage />
</ProtectedRoute>
}
/>
<Route
path="/profile"
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);
padding: 6px;
z-index: 100;
backdrop-filter: blur(20px) saturate(180%);
opacity: 0;
transform: translateY(8px);
pointer-events: none;

View File

@ -1,11 +1,12 @@
.card {
background: var(--color-bg-input-bar);
border: 1px solid var(--color-border-input-bar);
border-radius: 16px;
padding: 20px;
max-width: 680px;
background: transparent;
border: none;
border-radius: 0;
padding: 20px 0;
max-width: 800px;
width: 100%;
animation: cardFadeIn 0.3s ease-out;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
@keyframes cardFadeIn {
@ -17,61 +18,30 @@
.header {
display: flex;
gap: 12px;
margin-bottom: 16px;
margin-bottom: 12px;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(0, 184, 230, 0.12);
color: var(--color-primary);
.refColumn {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
flex-shrink: 0;
}
.headerContent {
.headerRight {
flex: 1;
min-width: 0;
}
.prompt {
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 */
/* Reference thumbnails row (legacy) */
.refRow {
display: flex;
gap: 6px;
margin-bottom: 12px;
margin-bottom: 10px;
}
.refThumb {
width: 48px;
height: 48px;
aspect-ratio: 3 / 4;
border-radius: 6px;
overflow: hidden;
background: #1a1a24;
@ -79,6 +49,15 @@
border: 1px solid #2a2a38;
}
.audioThumb {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
}
.refMedia {
width: 100%;
height: 100%;
@ -86,24 +65,191 @@
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 */
.resultArea {
border-radius: 12px;
overflow: hidden;
background: #0e0e16;
min-height: 200px;
background: rgba(0, 0, 0, 0.3);
aspect-ratio: 16 / 9;
max-height: 320px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.resultMedia {
width: 100%;
max-height: 400px;
height: 100%;
object-fit: contain;
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 {
display: flex;
flex-direction: column;
@ -114,23 +260,46 @@
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 {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 40px;
gap: 10px;
padding: 32px 40px;
width: 100%;
position: relative;
z-index: 1;
}
.loadingSpinner {
width: 36px;
height: 36px;
border: 3px solid #2a2a38;
width: 32px;
height: 32px;
border: 2.5px solid rgba(108, 99, 255, 0.15);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
animation: spin 1s linear infinite;
}
@keyframes spin {
@ -140,30 +309,42 @@
.loadingText {
font-size: 13px;
color: var(--color-text-secondary);
letter-spacing: 0.5px;
}
.progressBar {
width: 100%;
max-width: 300px;
height: 4px;
background: #2a2a38;
max-width: 200px;
height: 3px;
background: rgba(255, 255, 255, 0.06);
border-radius: 2px;
overflow: hidden;
}
.progressFill {
height: 100%;
background: var(--color-primary);
background: linear-gradient(90deg, var(--color-primary), #8b5cf6);
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 */
.actions {
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.actionBtn {
@ -191,3 +372,67 @@
border-color: rgba(255, 107, 107, 0.3);
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 { useGenerationStore } from '../store/generation';
import { showToast } from './Toast';
import styles from './GenerationCard.module.css';
const EditIcon = () => (
@ -16,13 +18,6 @@ const RefreshIcon = () => (
</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 = () => (
<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" />
@ -30,45 +25,184 @@ const VideoIcon = () => (
</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 {
task: GenerationTask;
onOpenDetail?: (task: GenerationTask) => void;
}
export function GenerationCard({ task }: Props) {
export function GenerationCard({ task, onOpenDetail }: Props) {
const removeTask = useGenerationStore((s) => s.removeTask);
const reEdit = useGenerationStore((s) => s.reEdit);
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 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 (
<div className={styles.card}>
{/* Header: avatar + prompt */}
{/* Header: reference thumbnails + prompt + meta labels */}
<div className={styles.header}>
<div className={styles.avatar}>
<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) */}
{/* Left: reference thumbnails */}
{task.references.length > 0 && (
<div className={styles.refRow}>
<div className={styles.refColumn}>
{task.references.map((ref) => (
<div key={ref.id} className={styles.refThumb}>
{ref.type === 'video' ? (
<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} />
)}
@ -76,55 +210,154 @@ export function GenerationCard({ task }: Props) {
))}
</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 */}
<div className={styles.resultArea}>
{isGenerating ? (
{/* Video / result area */}
<div className={styles.content}>
{isGenerating ? (
<div className={styles.resultArea}>
<div className={styles.shimmerBg} />
<div className={styles.generating}>
<div className={styles.loadingSpinner} />
<span className={styles.loadingText}>...</span>
<span className={styles.loadingText}></span>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${task.progress}%` }}
/>
</div>
<span className={styles.progressText}>{task.progress}%</span>
</div>
) : task.status === 'failed' ? (
<div className={styles.resultPlaceholder}>
<span style={{ color: '#e74c3c' }}>{task.errorMessage || '生成失败'}</span>
</div>
) : task.resultUrl ? (
</div>
) : task.status === 'failed' ? (
<p className={styles.errorText}>{task.errorMessage || '生成失败,请重试'}</p>
) : task.resultUrl ? (
<div
className={styles.resultArea}
onMouseEnter={handleVideoMouseEnter}
onMouseLeave={handleVideoMouseLeave}
onClick={handleVideoClick}
style={{ cursor: 'pointer' }}
>
<video
ref={videoRef}
src={task.resultUrl}
controls
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}>
<VideoIcon />
<span></span>
</div>
)}
</div>
</div>
)}
</div>
{/* Action buttons */}
{/* Bottom action buttons */}
{!isGenerating && (
<div className={styles.actions}>
<button className={styles.actionBtn} onClick={() => reEdit(task.id)}>
<EditIcon />
<span></span>
<EditIcon /> <span></span>
</button>
<button className={styles.actionBtn} onClick={() => regenerate(task.id)}>
<RefreshIcon />
<span></span>
</button>
<button className={`${styles.actionBtn} ${styles.deleteBtn}`} onClick={() => removeTask(task.id)}>
<TrashIcon />
<span></span>
<RefreshIcon /> <span></span>
</button>
<div className={styles.moreMenu} ref={moreRef}>
<button className={styles.moreBtn} onClick={() => setShowMore(!showMore)}>
<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>
{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>

View File

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

View File

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

View File

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

View File

@ -21,6 +21,7 @@ export function PromptInput() {
const [showMentionPopup, setShowMentionPopup] = useState(false);
const [mentionPos, setMentionPos] = useState({ top: 0, left: 0 });
const typedAtRef = useRef(false); // tracks if popup was triggered by typing @
const [highlightedIdx, setHighlightedIdx] = useState(0);
const [hoverRef, setHoverRef] = useState<UploadedFile | null>(null);
const [hoverPos, setHoverPos] = useState({ top: 0, left: 0 });
@ -137,6 +138,7 @@ export function PromptInput() {
}
setMentionPos({ top, left });
setHighlightedIdx(0);
setShowMentionPopup(true);
}, []);
@ -220,17 +222,36 @@ export function PromptInput() {
}, [extractText]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (showMentionPopup && e.key === 'Escape') {
e.preventDefault();
setShowMentionPopup(false);
if (showMentionPopup) {
if (e.key === 'Escape') {
e.preventDefault();
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) => {
e.preventDefault();
// Always paste as plain text for reliability, then rebuild mentions
const text = e.clipboardData.getData('text/plain');
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
const handleMouseOver = useCallback((e: React.MouseEvent) => {
@ -296,7 +317,7 @@ export function PromptInput() {
{references.map((ref, idx) => (
<button
key={`${ref.id}-${idx}`}
className={styles.mentionItem}
className={`${styles.mentionItem} ${idx === highlightedIdx ? styles.mentionItemActive : ''}`}
onMouseDown={(e) => {
e.preventDefault();
insertMention(ref);

View File

@ -1,8 +1,9 @@
.sidebar {
width: 60px;
width: 76px;
height: 100%;
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;
flex-direction: column;
align-items: center;
@ -12,14 +13,18 @@
}
.logo {
margin-bottom: 24px;
margin-bottom: 28px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.navItems {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.navItem {
@ -27,27 +32,105 @@
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 0;
color: #5a5a6a;
padding: 10px 0;
width: 56px;
border-radius: 8px;
color: var(--color-text-disabled);
cursor: pointer;
transition: color 0.15s;
transition: color 0.15s, background 0.15s;
font-size: 11px;
user-select: none;
}
.navItem:hover {
color: #b0b0c0;
color: var(--color-text-secondary);
background: rgba(255, 255, 255, 0.04);
}
.navItem.active {
color: var(--color-primary);
background: var(--color-sidebar-active);
}
.bottomItems {
/* Bottom section */
.bottom {
margin-top: auto;
display: flex;
flex-direction: column;
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) {

View File

@ -1,73 +1,86 @@
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../store/auth';
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() {
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 (
<aside className={styles.sidebar}>
<div className={styles.logo}>
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<path d="M4 8L14 2L24 8V20L14 26L4 20V8Z" fill="#00b8e6" opacity="0.9" />
<path d="M14 2L24 8L14 14L4 8L14 2Z" fill="#33ccf0" />
{/* Logo */}
<div className={styles.logo} onClick={() => navigate('/')}>
<svg width="32" height="32" viewBox="0 0 28 28" fill="none">
<path d="M4 8L14 2L24 8V20L14 26L4 20V8Z" fill="#6c63ff" opacity="0.9" />
<path d="M14 2L24 8L14 14L4 8L14 2Z" fill="#8b83ff" />
<path d="M10 10L18 6" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" opacity="0.6" />
</svg>
</div>
<div className={styles.navItems}>
{sidebarItems.map((item) => (
<div
key={item.name}
className={`${styles.navItem} ${item.active ? styles.active : ''}`}
title={item.name}
>
{item.icon}
<span>{item.name}</span>
</div>
))}
</div>
<div className={styles.bottomItems}>
<div className={styles.navItem} title="API">
<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" />
{/* Nav items */}
<nav className={styles.navItems}>
<div
className={`${styles.navItem} ${isActive('/') ? styles.active : ''}`}
onClick={() => navigate('/')}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span style={{ fontSize: 10 }}>API</span>
<span></span>
</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>
{/* Admin entry */}
{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">
<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>
</div>
)}
{/* User avatar */}
<div
className={styles.avatar}
onClick={() => navigate('/profile')}
title={user?.username || '个人中心'}
>
{user?.username?.charAt(0).toUpperCase() || 'U'}
</div>
</div>
</aside>

View File

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

View File

@ -39,16 +39,6 @@
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 {
width: var(--send-btn-size);
height: var(--send-btn-size);

View File

@ -70,7 +70,8 @@ const generationTypeItems = [
];
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 = [
@ -79,12 +80,12 @@ const modeItems = [
];
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: '16:9', value: '16:9' 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: '9:16', value: '9:16' as AspectRatio },
];
const durationItems = Array.from({ length: 12 }, (_, i) => {
@ -143,11 +144,20 @@ export function Toolbar() {
<span className={styles.label}></span>
</button>
{/* Model — fixed to Seedance 2.0 */}
<button className={styles.btn}>
<DiamondIcon />
<span className={styles.label}>Seedance 2.0</span>
</button>
{/* Model selector */}
<Dropdown
items={modelItems}
value={model}
onSelect={(v) => setModel(v as ModelOption)}
minWidth={160}
trigger={
<button className={styles.btn}>
{model === 'seedance_2.0_fast' ? <LightningIcon /> : <DiamondIcon />}
<span className={styles.label}>{model === 'seedance_2.0_fast' ? 'AirDrama Fast' : 'AirDrama'}</span>
<ChevronDown />
</button>
}
/>
{/* Mode selector */}
<Dropdown
@ -209,15 +219,6 @@ export function Toolbar() {
{/* 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 */}
<button
className={`${styles.sendBtn} ${isSubmittable ? styles.sendEnabled : styles.sendDisabled}`}

View File

@ -1,8 +1,15 @@
.wrapper {
flex-shrink: 0;
display: flex;
gap: 8px;
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 {
@ -10,8 +17,8 @@
}
.trigger {
width: var(--thumbnail-size);
height: var(--thumbnail-size);
aspect-ratio: 3 / 4;
border: 1.5px dashed #3a3a48;
background: rgba(255, 255, 255, 0.03);
border-radius: var(--radius-btn);
@ -35,32 +42,56 @@
color: var(--color-text-disabled);
}
/* Single row container for all thumbnails */
/* Always absolute — no position toggling to avoid jitter */
.thumbRow {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: flex-end;
align-items: flex-start;
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 {
position: relative;
width: 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);
overflow: hidden;
background: #1a1a24;
flex-shrink: 0;
border: 1.5px solid #2a2a38;
cursor: default;
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;
position: relative;
}
.thumbItem:hover {
.thumbItem:hover .thumbInner {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
}
@ -71,7 +102,7 @@
display: block;
}
/* Close / remove button */
/* Close / remove button — inside thumbInner */
.thumbClose {
position: absolute;
top: 4px;
@ -97,7 +128,30 @@
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 {
position: absolute;
bottom: 0;
@ -113,16 +167,18 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-radius: 0 0 var(--radius-thumbnail) var(--radius-thumbnail);
}
.itemExpanded .thumbLabel {
opacity: 1;
}
/* Add more button */
/* Add more button — 3:4 to match thumbnails */
.addMore {
width: var(--thumbnail-size);
position: relative;
height: var(--thumbnail-size);
aspect-ratio: 3 / 4;
border: 1.5px dashed #3a3a48;
background: rgba(255, 255, 255, 0.03);
border-radius: var(--radius-btn);
@ -133,6 +189,7 @@
gap: 4px;
cursor: pointer;
flex-shrink: 0;
overflow: visible;
transition:
margin-left 0.35s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.25s,
@ -145,32 +202,82 @@
background: rgba(255, 255, 255, 0.06);
}
.addMoreHidden {
opacity: 0;
pointer-events: none;
}
.addMoreVisible {
opacity: 1;
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 {
position: absolute;
bottom: -2px;
right: -6px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--color-primary);
bottom: -6px;
width: 28px;
height: 28px;
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;
font-size: 11px;
font-weight: 600;
font-size: 16px;
font-weight: 400;
display: flex;
align-items: center;
justify-content: center;
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 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() {
const references = useInputBarStore((s) => s.references);
const addReferences = useInputBarStore((s) => s.addReferences);
const removeReference = useInputBarStore((s) => s.removeReference);
const fileInputRef = useRef<HTMLInputElement>(null);
const [expanded, setExpanded] = useState(false);
const [badgeHover, setBadgeHover] = useState(false);
const handleTrigger = () => {
fileInputRef.current?.click();
@ -18,19 +35,22 @@ export function UniversalUpload() {
const files = Array.from(e.target.files || []);
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[] = [];
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) {
showToast(f.type.startsWith('video/') ? '视频文件不能超过100MB' : '图片文件不能超过20MB');
showToast(limitLabel);
} else {
valid.push(f);
}
@ -38,21 +58,29 @@ export function UniversalUpload() {
if (!valid.length) { e.target.value = ''; return; }
addReferences(valid);
if (valid.length > remaining) {
showToast('最多上传5张参考内容');
}
e.target.value = '';
};
const hasFiles = references.length > 0;
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 (
<div className={styles.wrapper}>
<div
className={`${styles.wrapper} ${hasFiles ? styles.wrapperActive : ''}`}
style={hasFiles ? { width: stackWidth, height: THUMB_H } : undefined}
>
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*"
accept="image/*,video/*,audio/*"
multiple
className={styles.hiddenInput}
onChange={handleFileChange}
@ -69,64 +97,80 @@ export function UniversalUpload() {
</div>
)}
{/* Thumbnails row - single container, animate via CSS transitions */}
{/* Thumbnails — thumbRow always absolute, hover to expand */}
{hasFiles && (
<div
className={styles.thumbRow}
onMouseEnter={() => setExpanded(true)}
onMouseLeave={() => setExpanded(false)}
>
{references.map((ref, i) => (
<div
key={ref.id}
className={`${styles.thumbItem} ${expanded ? styles.itemExpanded : ''}`}
style={{
marginLeft: i === 0 ? 0 : (expanded ? 8 : -64),
zIndex: expanded ? 1 : count - i,
transform: expanded
? 'rotate(0deg) translateY(0px)'
: `rotate(${i * -2.5}deg) translateY(${i * -2}px)`,
}}
>
{ref.type === 'video' ? (
<video src={ref.previewUrl} className={styles.thumbMedia} muted />
) : (
<img src={ref.previewUrl} alt={ref.label} className={styles.thumbMedia} />
)}
<>
<div
className={`${styles.thumbRow} ${expanded ? styles.thumbRowExpanded : ''}`}
onMouseEnter={() => setExpanded(true)}
onMouseLeave={() => setExpanded(false)}
>
{references.map((ref, i) => (
<div
className={styles.thumbClose}
onClick={(e) => { e.stopPropagation(); removeReference(ref.id); }}
key={ref.id}
className={`${styles.thumbItem} ${expanded ? styles.itemExpanded : ''}`}
style={{
marginLeft: i === 0 ? 0 : (expanded ? 8 : -48),
zIndex: expanded ? 1 : count - i,
}}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
<div className={styles.thumbInner}>
{ref.type === 'video' ? (
<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} />
)}
<div
className={styles.thumbClose}
onClick={(e) => { e.stopPropagation(); removeReference(ref.id); }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</div>
<div className={styles.thumbLabel}>{ref.label}</div>
</div>
<div className={styles.thumbTooltip}>{ref.label}</div>
</div>
<div className={styles.thumbLabel}>{ref.label}</div>
</div>
))}
))}
{/* Add more button */}
{references.length < 5 && (
{/* Add more button (expanded state only) */}
{expanded && !allFull && (
<div
className={`${styles.addMore} ${styles.addMoreVisible}`}
style={{ marginLeft: 8 }}
onClick={(e) => { e.stopPropagation(); handleTrigger(); }}
>
<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="5" y1="12" x2="19" y2="12" />
</svg>
<div className={styles.addMoreTooltip}></div>
</div>
)}
</div>
{/* "+" badge — outside thumbRow, position based on stack width */}
{!expanded && !allFull && (
<div
className={`${styles.addMore} ${expanded ? styles.addMoreVisible : styles.addMoreHidden}`}
style={{
marginLeft: expanded ? 8 : -64,
}}
className={styles.countBadge}
style={{ left: stackWidth - 14 }}
onClick={(e) => { e.stopPropagation(); handleTrigger(); }}
onMouseEnter={() => setBadgeHover(true)}
onMouseLeave={() => setBadgeHover(false)}
>
<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="5" y1="12" x2="19" y2="12" />
</svg>
+
{badgeHover && (
<div className={styles.badgeTooltip}></div>
)}
</div>
)}
{/* Count badge when collapsed */}
{!expanded && count > 1 && (
<div className={styles.countBadge}>{count}</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 {
display: flex;
height: 100%;
position: relative;
z-index: 2;
}
.main {

View File

@ -1,17 +1,22 @@
import { useRef, useEffect } from 'react';
import { useRef, useEffect, useState, useMemo } from 'react';
import { Sidebar } from './Sidebar';
import { InputBar } from './InputBar';
import { GenerationCard } from './GenerationCard';
import { Toast } from './Toast';
import { UserInfoBar } from './UserInfoBar';
import { VideoDetailModal } from './VideoDetailModal';
import { useGenerationStore } from '../store/generation';
import type { GenerationTask } from '../types';
import styles from './VideoGenerationPage.module.css';
export function VideoGenerationPage() {
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 scrollRef = useRef<HTMLDivElement>(null);
const prevCountRef = useRef(tasks.length);
const [detailTask, setDetailTask] = useState<GenerationTask | null>(null);
// Load tasks from backend on mount (persist across page refresh)
useEffect(() => {
@ -21,16 +26,36 @@ export function VideoGenerationPage() {
// Auto-scroll to top when new task is added
useEffect(() => {
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;
}, [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 (
<div className={styles.layout}>
<Sidebar />
<main className={styles.main}>
<UserInfoBar />
<div className={styles.contentArea} ref={scrollRef}>
{tasks.length === 0 ? (
<div className={styles.emptyArea}>
@ -39,7 +64,11 @@ export function VideoGenerationPage() {
) : (
<div className={styles.taskList}>
{tasks.map((task) => (
<GenerationCard key={task.id} task={task} />
<GenerationCard
key={task.id}
task={task}
onOpenDetail={setDetailTask}
/>
))}
</div>
)}
@ -47,6 +76,17 @@ export function VideoGenerationPage() {
<InputBar />
</main>
<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>
);
}

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');
:root {
--color-bg-page: #0a0a0f;
--color-bg-input-bar: #16161e;
--color-border-input-bar: #2a2a38;
--color-primary: #00b8e6;
--color-text-primary: #ffffff;
--color-text-secondary: #8a8a9a;
--color-text-disabled: #4a4a5a;
--color-bg-hover: rgba(255, 255, 255, 0.06);
--color-bg-dropdown: #1e1e2a;
--color-bg-page: #07070f;
--color-bg-input-bar: rgba(255, 255, 255, 0.06);
--color-border-input-bar: rgba(255, 255, 255, 0.10);
--color-primary: #6c63ff;
--color-text-primary: #f1f0ff;
--color-text-secondary: #8b8ea8;
--color-text-disabled: #4c4f6b;
--color-bg-hover: rgba(255, 255, 255, 0.08);
--color-bg-dropdown: rgba(13, 13, 26, 0.92);
--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-active: #00b8e6;
--color-sidebar-bg: #0e0e14;
--color-btn-send-active: #6c63ff;
--color-sidebar-bg: rgba(7, 7, 15, 0.80);
/* 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-hover: rgba(255, 255, 255, 0.04);
--color-bg-card: #16161e;
--color-border-card: #2a2a38;
--color-bg-card: rgba(255, 255, 255, 0.06);
--color-border-card: rgba(255, 255, 255, 0.10);
--color-success: #00b894;
--color-danger: #e74c3c;
--color-warning: #f39c12;
@ -61,13 +61,162 @@ body {
-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 {
width: 4px;
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-border-input-bar);
border-radius: 4px;
background: transparent;
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) =>
api.get<BackendTask>(`/video/tasks/${taskId}`),
deleteTask: (taskId: string) =>
api.delete(`/video/tasks/${taskId}`),
};
// Admin APIs

View File

@ -87,6 +87,12 @@
color: var(--color-text-primary);
}
.navDivider {
height: 1px;
background: var(--color-border-card);
margin: 4px 12px;
}
.sidebarFooter {
padding: 12px;
border-top: 1px solid var(--color-border-card);
@ -95,26 +101,6 @@
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 {
display: flex;
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)">
<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>
{!collapsed && <span className={styles.logoText}>Jimeng Admin</span>}
{!collapsed && <span className={styles.logoText}>AirDrama Admin</span>}
</div>
<button className={styles.collapseBtn} onClick={() => setCollapsed(!collapsed)}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="var(--color-text-secondary)">
@ -43,6 +43,13 @@ export function AdminLayout() {
</div>
<nav className={styles.nav}>
<button className={styles.navItem} onClick={() => navigate('/')} style={{ border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left', width: '100%' }}>
<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) => (
<NavLink
key={item.path}
@ -60,12 +67,6 @@ export function AdminLayout() {
</nav>
<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.userAvatar}>{user?.username.charAt(0).toUpperCase()}</div>
{!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;
background: var(--color-bg-page);
padding: 20px;
position: relative;
z-index: 2;
}
.card {

View File

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

View File

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

View File

@ -1,9 +1,8 @@
.page {
max-width: 720px;
max-width: 1200px;
margin: 0 auto;
padding: 24px 20px 60px;
height: 100vh;
overflow-y: auto;
min-height: 100vh;
}
/* Header */
@ -105,28 +104,11 @@
/* Overview grid */
.overviewGrid {
display: grid;
grid-template-columns: 180px 1fr 1fr;
grid-template-columns: 1fr 1fr 1fr;
gap: 14px;
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 {
background: var(--color-bg-card);
border: 1px solid var(--color-border-card);
@ -356,11 +338,17 @@
.skeletonCards {
display: grid;
grid-template-columns: 180px 1fr 1fr;
grid-template-columns: 1fr 1fr 1fr;
gap: 14px;
height: 180px;
}
@media (max-width: 900px) {
.skeletonCards {
grid-template-columns: 1fr 1fr;
}
}
.skeletonCards::before,
.skeletonCards::after {
content: '';
@ -381,6 +369,12 @@
50% { opacity: 0.5; }
}
@media (max-width: 900px) {
.overviewGrid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 640px) {
.overviewGrid {
grid-template-columns: 1fr;

View File

@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import ReactEChartsCore from 'echarts-for-react/lib/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 { CanvasRenderer } from 'echarts/renderers';
import { useAuthStore } from '../store/auth';
@ -11,7 +11,7 @@ import type { ProfileOverview, AdminRecord } from '../types';
import { showToast } from '../components/Toast';
import styles from './ProfilePage.module.css';
echarts.use([GaugeChart, LineChart, GridComponent, TooltipComponent, CanvasRenderer]);
echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]);
export function ProfilePage() {
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 monthlyPercent = overview.monthly_seconds_limit > 0 ? (overview.monthly_seconds_used / overview.monthly_seconds_limit) * 100 : 0;
const gaugeOption: echarts.EChartsCoreOption = {
series: [{
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 totalRemaining = Math.max(0, overview.monthly_seconds_limit - overview.total_seconds_used);
const totalPercent = overview.monthly_seconds_limit > 0 ? (overview.total_seconds_used / overview.monthly_seconds_limit) * 100 : 0;
const sparklineOption: echarts.EChartsCoreOption = {
tooltip: {
@ -162,9 +133,16 @@ export function ProfilePage() {
<div className={styles.overviewSection}>
<h2 className={styles.sectionTitle}></h2>
<div className={styles.overviewGrid}>
<div className={styles.gaugeCard}>
<ReactEChartsCore echarts={echarts} option={gaugeOption} style={{ height: 180, width: 180 }} />
<div className={styles.gaugeLabel}></div>
<div className={styles.quotaCard}>
<div className={styles.quotaLabel}></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 className={styles.quotaCard}>
<div className={styles.quotaLabel}></div>

View File

@ -5,6 +5,42 @@ import { videoApi, mediaApi } from '../lib/api';
import { useAuthStore } from './auth';
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
function mapStatus(backendStatus: string): 'generating' | 'completed' | 'failed' {
if (backendStatus === 'completed' || backendStatus === 'succeeded') return 'completed';
@ -41,7 +77,7 @@ function backendToFrontend(bt: BackendTask): GenerationTask {
status: mapStatus(bt.status),
progress: mapProgress(bt.status),
resultUrl: bt.result_url || undefined,
errorMessage: bt.error_message || undefined,
errorMessage: mapErrorMessage(bt.error_message),
createdAt: new Date(bt.created_at).getTime(),
};
}
@ -65,7 +101,7 @@ function startPolling(taskId: string, frontendId: string) {
status: newStatus,
progress: newStatus === 'completed' ? 100 : newStatus === 'failed' ? 0 : Math.min(t.progress + 5, 90),
resultUrl: data.result_url || t.resultUrl,
errorMessage: data.error_message || t.errorMessage,
errorMessage: mapErrorMessage(data.error_message) || t.errorMessage,
}
: t
),
@ -110,19 +146,139 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
loadTasks: async () => {
set({ isLoading: true });
let tasks: GenerationTask[] = [];
try {
const { data } = await videoApi.getTasks();
const tasks = data.results.map(backendToFrontend);
set({ tasks, isLoading: false });
// Start polling for any active tasks
for (const task of tasks) {
if (task.status === 'generating' && task.taskId) {
startPolling(task.taskId, task.id);
}
}
tasks = data.results.map(backendToFrontend).reverse();
} catch {
set({ isLoading: false });
// 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 });
// Start polling for any active tasks
for (const task of tasks) {
if (task.status === 'generating' && task.taskId) {
startPolling(task.taskId, task.id);
}
}
},
@ -131,15 +287,17 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
if (!input.canSubmit()) return null;
// 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') {
for (const ref of input.references) {
if (ref.tosUrl) {
// Already uploaded to TOS (regeneration)
filesToUpload.push({ tosUrl: ref.tosUrl, type: ref.type, role: ref.type === 'video' ? 'reference_video' : 'reference_image', label: ref.label });
filesToUpload.push({ tosUrl: ref.tosUrl, type: ref.type, role: getRoleForType(ref.type), label: ref.label });
} 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 {
@ -196,7 +354,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
createdAt: Date.now(),
};
set((s) => ({ tasks: [placeholderTask, ...s.tasks] }));
set((s) => ({ tasks: [...s.tasks, placeholderTask] }));
// Clear input
useInputBarStore.setState({
@ -301,7 +459,11 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
removeTask: (id) => {
stopPolling(id);
const task = get().tasks.find((t) => t.id === id);
set((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) }));
if (task?.taskId) {
videoApi.deleteTask(task.taskId).catch(() => {});
}
},
reEdit: (id) => {

View File

@ -1,8 +1,14 @@
import { create } from 'zustand';
import type { CreationMode, ModelOption, AspectRatio, Duration, GenerationType, UploadedFile } from '../types';
import { showToast } from '../components/Toast';
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 {
// Generation type
generationType: GenerationType;
@ -36,6 +42,7 @@ interface InputBarState {
// Universal references
references: UploadedFile[];
prevReferences: UploadedFile[];
addReferences: (files: File[]) => void;
removeReference: (id: string) => void;
clearReferences: () => void;
@ -91,24 +98,42 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
setEditorHtml: (editorHtml) => set({ editorHtml }),
references: [],
prevReferences: [],
addReferences: (files) => {
const state = get();
const remaining = 5 - state.references.length;
if (remaining <= 0) return;
const toAdd = files.slice(0, remaining);
const newRefs: UploadedFile[] = toAdd.map((file) => {
// Count existing references by type
const counts = { image: 0, video: 0, audio: 0 };
for (const ref of state.references) counts[ref.type]++;
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++;
const type = file.type.startsWith('video') ? 'video' as const : 'image' as const;
const labelPrefix = type === 'video' ? '视频' : '图片';
return {
const labelPrefix = type === 'video' ? '视频' : type === 'audio' ? '音频' : '图片';
newRefs.push({
id: `ref_${fileCounter}`,
file,
type,
previewUrl: URL.createObjectURL(file),
previewUrl: type === 'audio' ? '' : URL.createObjectURL(file),
label: `${labelPrefix}${fileCounter}`,
};
});
set({ references: [...state.references, ...newRefs] });
});
counts[type]++;
}
if (newRefs.length > 0) {
set({ references: [...state.references, ...newRefs] });
}
},
removeReference: (id) => {
const state = get();
@ -168,7 +193,13 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
state.mode === 'universal'
? state.references.length > 0
: 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,
@ -179,10 +210,9 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
if (state.mode === mode) return;
if (mode === 'keyframe') {
// Clear universal references
state.references.forEach((r) => URL.revokeObjectURL(r.previewUrl));
set({
mode,
prevReferences: state.references,
references: [],
duration: 5,
});
@ -194,6 +224,8 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
mode,
firstFrame: null,
lastFrame: null,
references: state.prevReferences,
prevReferences: [],
aspectRatio: state.prevAspectRatio,
duration: state.prevDuration,
});
@ -207,6 +239,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
reset: () => {
const state = get();
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.lastFrame) URL.revokeObjectURL(state.lastFrame.previewUrl);
set({
@ -219,6 +252,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
prompt: '',
editorHtml: '',
references: [],
prevReferences: [],
firstFrame: null,
lastFrame: null,
generationType: 'video',

View File

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