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:
parent
c1f29cbf85
commit
f8358a28c6
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
24
web/package-lock.json
generated
@ -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",
|
||||
|
||||
BIN
web/public/demo/demo-16-9.mp4
Normal file
BIN
web/public/demo/demo-16-9.mp4
Normal file
Binary file not shown.
BIN
web/public/demo/demo-21-9.mp4
Normal file
BIN
web/public/demo/demo-21-9.mp4
Normal file
Binary file not shown.
BIN
web/public/demo/demo-9-16.mp4
Normal file
BIN
web/public/demo/demo-9-16.mp4
Normal file
Binary file not shown.
@ -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={
|
||||
|
||||
48
web/src/components/AmbientBackground.tsx
Normal file
48
web/src/components/AmbientBackground.tsx
Normal 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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-20px);
|
||||
background: var(--color-bg-dropdown);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}`}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
499
web/src/components/VideoDetailModal.module.css
Normal file
499
web/src/components/VideoDetailModal.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
487
web/src/components/VideoDetailModal.tsx
Normal file
487
web/src/components/VideoDetailModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.main {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 && (
|
||||
|
||||
167
web/src/pages/AssetsPage.module.css
Normal file
167
web/src/pages/AssetsPage.module.css
Normal 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;
|
||||
}
|
||||
176
web/src/pages/AssetsPage.tsx
Normal file
176
web/src/pages/AssetsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -5,6 +5,8 @@
|
||||
justify-content: center;
|
||||
background: var(--color-bg-page);
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.card {
|
||||
|
||||
@ -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',
|
||||
},
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user