diff --git a/backend/apps/generation/migrations/0009_generationrecord_is_favorited.py b/backend/apps/generation/migrations/0009_generationrecord_is_favorited.py new file mode 100644 index 0000000..4068732 --- /dev/null +++ b/backend/apps/generation/migrations/0009_generationrecord_is_favorited.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.29 on 2026-03-22 11:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('generation', '0008_asset_library'), + ] + + operations = [ + migrations.AddField( + model_name='generationrecord', + name='is_favorited', + field=models.BooleanField(default=False, verbose_name='已收藏'), + ), + ] diff --git a/backend/apps/generation/models.py b/backend/apps/generation/models.py index 18c9cd4..72045ce 100644 --- a/backend/apps/generation/models.py +++ b/backend/apps/generation/models.py @@ -44,6 +44,7 @@ class GenerationRecord(models.Model): result_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='生成结果URL') error_message = models.TextField(blank=True, default='', verbose_name='错误信息') reference_urls = models.JSONField(default=list, blank=True, verbose_name='参考素材信息') + is_favorited = models.BooleanField(default=False, verbose_name='已收藏') created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间') class Meta: diff --git a/backend/apps/generation/urls.py b/backend/apps/generation/urls.py index e2ffdba..18993f1 100644 --- a/backend/apps/generation/urls.py +++ b/backend/apps/generation/urls.py @@ -8,6 +8,7 @@ urlpatterns = [ path('video/generate', views.video_generate_view, name='video_generate'), path('video/tasks', views.video_tasks_list_view, name='video_tasks_list'), path('video/tasks/', views.video_task_detail_view, name='video_task_detail'), + path('video/tasks//favorite', views.video_task_toggle_favorite_view, name='video_task_toggle_favorite'), # Public announcement path('announcement', views.announcement_view, name='announcement'), diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index 922d718..49090c1 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -540,10 +540,25 @@ def _serialize_task(record): 'result_url': d.get('result_url', ''), 'error_message': d.get('error_message', ''), 'reference_urls': d.get('reference_urls') or [], + 'is_favorited': record.is_favorited, 'created_at': record.created_at.isoformat(), } +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def video_task_toggle_favorite_view(request, task_id): + """POST /api/v1/video/tasks//favorite — Toggle favorite.""" + try: + record = GenerationRecord.objects.get(task_id=task_id, user=request.user) + except GenerationRecord.DoesNotExist: + return Response({'error': '任务不存在'}, status=status.HTTP_404_NOT_FOUND) + + record.is_favorited = not record.is_favorited + record.save(update_fields=['is_favorited']) + return Response({'is_favorited': record.is_favorited}) + + # ────────────────────────────────────────────── # Admin: Dashboard Stats # ────────────────────────────────────────────── diff --git a/web/src/components/GenerationCard.module.css b/web/src/components/GenerationCard.module.css index 23b5581..88158c1 100644 --- a/web/src/components/GenerationCard.module.css +++ b/web/src/components/GenerationCard.module.css @@ -236,8 +236,10 @@ inset: 0; background: transparent; display: flex; - align-items: flex-start; - justify-content: flex-end; + flex-direction: column; + align-items: flex-end; + justify-content: flex-start; + gap: 8px; padding: 12px; animation: overlayFadeIn 0.15s ease-out; } diff --git a/web/src/components/GenerationCard.tsx b/web/src/components/GenerationCard.tsx index 65da072..4c40f83 100644 --- a/web/src/components/GenerationCard.tsx +++ b/web/src/components/GenerationCard.tsx @@ -122,6 +122,7 @@ 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 toggleFavorite = useGenerationStore((s) => s.toggleFavorite); const videoRef = useRef(null); const moreRef = useRef(null); @@ -450,6 +451,11 @@ export function GenerationCard({ task, onOpenDetail }: Props) { + )} diff --git a/web/src/components/InputBar.tsx b/web/src/components/InputBar.tsx index d1f3d8b..6a4c0a9 100644 --- a/web/src/components/InputBar.tsx +++ b/web/src/components/InputBar.tsx @@ -81,9 +81,8 @@ export function InputBar() { const firstFrame = useInputBarStore((s) => s.firstFrame); const lastFrame = useInputBarStore((s) => s.lastFrame); - // 联网搜索仅支持纯文生视频(无参考图/视频/音频/素材) - const hasMedia = references.length > 0 || !!firstFrame || !!lastFrame || editorHtml.includes('data-ref-type="asset"'); - const searchDisabled = hasMedia; + // 联网搜索暂未开放 + const searchDisabled = true; return (
diff --git a/web/src/components/PromptInput.tsx b/web/src/components/PromptInput.tsx index 4f15211..3457ea9 100644 --- a/web/src/components/PromptInput.tsx +++ b/web/src/components/PromptInput.tsx @@ -75,11 +75,15 @@ export function PromptInput() { if (opts.assetGroupId) span.dataset.assetGroupId = opts.assetGroupId; if (opts.groupName) span.dataset.groupName = opts.groupName; - if (opts.thumbUrl) { + if (opts.refType === 'audio') { + const icon = document.createElement('span'); + icon.textContent = '\u266B'; + icon.style.cssText = 'margin-right:3px;font-size:13px;vertical-align:middle;pointer-events:none'; + span.appendChild(icon); + } else if (opts.thumbUrl) { const img = document.createElement('img'); img.src = tosThumb(opts.thumbUrl, 32); img.className = styles.mentionImg; - // 显式设置尺寸,防止 CSS class 未生效时图片为 0x0 img.setAttribute('width', '16'); img.setAttribute('height', '16'); img.style.cssText = 'width:16px;height:16px;border-radius:3px;object-fit:cover;vertical-align:middle;margin-right:3px;display:inline-block;pointer-events:none'; @@ -476,6 +480,9 @@ export function PromptInput() { const refId = target.dataset.refId; const refType = target.dataset.refType; + // 音频标签不显示 hover 预览 + if (refType === 'audio') return; + // 参考图:从 references 中查找 let found = references.find((r) => r.id === refId); diff --git a/web/src/components/VideoDetailModal.module.css b/web/src/components/VideoDetailModal.module.css index 16f6358..b1d8cd0 100644 --- a/web/src/components/VideoDetailModal.module.css +++ b/web/src/components/VideoDetailModal.module.css @@ -60,6 +60,35 @@ background: rgba(255, 255, 255, 0.12); } +.floatingActions { + position: absolute; + top: 68px; + right: 20px; + z-index: 10; + display: flex; + flex-direction: column; + gap: 8px; +} + +.floatingBtn { + width: 36px; + height: 36px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.08); + border: none; + color: rgba(255, 255, 255, 0.6); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: color 0.15s, background 0.15s; +} + +.floatingBtn:hover { + color: #fff; + background: rgba(255, 255, 255, 0.15); +} + /* Video area — centres the player */ .videoArea { flex: 1; diff --git a/web/src/components/VideoDetailModal.tsx b/web/src/components/VideoDetailModal.tsx index dbb0952..b75e99c 100644 --- a/web/src/components/VideoDetailModal.tsx +++ b/web/src/components/VideoDetailModal.tsx @@ -15,6 +15,7 @@ interface Props { onReEdit?: (id: string) => void; onRegenerate?: (id: string) => void; onDelete?: (id: string) => void; + onToggleFavorite?: (id: string) => void; hideReEdit?: boolean; onPrev?: () => void; onNext?: () => void; @@ -22,7 +23,7 @@ interface Props { hasNext?: boolean; } -export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDelete, onPrev, onNext, hasPrev, hasNext, hideReEdit }: Props) { +export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDelete, onToggleFavorite, onPrev, onNext, hasPrev, hasNext, hideReEdit }: Props) { const navigate = useNavigate(); const videoRef = useRef(null); const videoContainerRef = useRef(null); @@ -438,8 +439,12 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele 下载
-
); diff --git a/web/src/pages/AssetsPage.tsx b/web/src/pages/AssetsPage.tsx index 36876e0..8f827b6 100644 --- a/web/src/pages/AssetsPage.tsx +++ b/web/src/pages/AssetsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef, useMemo } from 'react'; +import { useEffect, useState, useRef, useMemo, useCallback } from 'react'; import { Sidebar } from '../components/Sidebar'; import { VideoDetailModal } from '../components/VideoDetailModal'; import { useGenerationStore } from '../store/generation'; @@ -91,19 +91,28 @@ export function AssetsPage() { const reEdit = useGenerationStore((s) => s.reEdit); const regenerate = useGenerationStore((s) => s.regenerate); const removeTask = useGenerationStore((s) => s.removeTask); - const [detailTask, setDetailTask] = useState(null); + const [detailTaskId, setDetailTaskId] = useState(null); + const detailTask = useMemo(() => detailTaskId ? tasks.find((t) => t.id === detailTaskId) || null : null, [detailTaskId, tasks]); + const setDetailTask = useCallback((t: GenerationTask | null) => setDetailTaskId(t?.id || null), []); const [confirmDeleteId, setConfirmDeleteId] = useState(null); useEffect(() => { loadTasks(); }, [loadTasks]); + const [subTab, setSubTab] = useState<'all' | 'favorites'>('all'); + const completedTasks = useMemo( () => tasks.filter((t) => t.status === 'completed'), [tasks], ); - const dateGroups = useMemo(() => groupByDate(completedTasks), [completedTasks]); + const displayTasks = useMemo( + () => subTab === 'favorites' ? completedTasks.filter((t) => t.isFavorited) : completedTasks, + [completedTasks, subTab], + ); + + const dateGroups = useMemo(() => groupByDate(displayTasks), [displayTasks]); const handleReEdit = (id: string) => { reEdit(id); @@ -127,7 +136,7 @@ export function AssetsPage() { setConfirmDeleteId(null); }; - const detailIdx = detailTask ? completedTasks.findIndex((t) => t.id === detailTask.id) : -1; + const detailIdx = detailTask ? displayTasks.findIndex((t) => t.id === detailTask.id) : -1; return (
@@ -139,16 +148,16 @@ export function AssetsPage() { 视频
- 所有视频 - 我的收藏 + setSubTab('all')} style={{ cursor: 'pointer' }}>所有视频 + setSubTab('favorites')} style={{ cursor: 'pointer' }}>我的收藏
{/* Video grid by date */}
- {completedTasks.length === 0 ? ( + {displayTasks.length === 0 ? (
-

暂无已完成的视频

+

{subTab === 'favorites' ? '暂无收藏的视频' : '暂无已完成的视频'}

) : ( dateGroups.map((group) => ( @@ -175,10 +184,11 @@ export function AssetsPage() { onReEdit={handleReEdit} onRegenerate={handleRegenerate} onDelete={handleDelete} + onToggleFavorite={(id) => { useGenerationStore.getState().toggleFavorite(id); }} 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])} + hasNext={detailIdx >= 0 && detailIdx < displayTasks.length - 1} + onPrev={() => detailIdx > 0 && setDetailTask(displayTasks[detailIdx - 1])} + onNext={() => detailIdx < displayTasks.length - 1 && setDetailTask(displayTasks[detailIdx + 1])} /> setDetailTask(null)} + {...(() => { + if (!detailTask || !expandedMember || !memberVideos[expandedMember]) return {}; + const vids = memberVideos[expandedMember].videos; + const idx = vids.findIndex((v) => String(v.id) === detailTask.id); + if (idx < 0) return {}; + return { + hasPrev: idx > 0, + hasNext: idx < vids.length - 1, + onPrev: () => idx > 0 && setDetailTask(assetVideoToTask(vids[idx - 1])), + onNext: () => idx < vids.length - 1 && setDetailTask(assetVideoToTask(vids[idx + 1])), + }; + })()} />
); diff --git a/web/src/store/generation.ts b/web/src/store/generation.ts index c54aaf9..d4158c9 100644 --- a/web/src/store/generation.ts +++ b/web/src/store/generation.ts @@ -105,6 +105,7 @@ function backendToFrontend(bt: BackendTask): GenerationTask { createdAt: new Date(bt.created_at).getTime(), tokensConsumed: bt.tokens_consumed || 0, costAmount: bt.cost_amount || 0, + isFavorited: bt.is_favorited || false, }; } @@ -165,6 +166,8 @@ function startPolling(taskId: string, frontendId: string) { progress: newStatus === 'completed' ? 100 : newStatus === 'failed' ? 0 : t.progress, resultUrl: data.result_url || t.resultUrl, errorMessage: mapErrorMessage(data.error_message) || t.errorMessage, + tokensConsumed: data.tokens_consumed ?? t.tokensConsumed, + costAmount: data.cost_amount ?? t.costAmount, } : t ), @@ -208,6 +211,7 @@ interface GenerationState { savedScrollTop: number | null; addTask: () => Promise; removeTask: (id: string) => void; + toggleFavorite: (id: string) => Promise; reEdit: (id: string) => void; regenerate: (id: string) => void; loadTasks: () => Promise; @@ -368,6 +372,7 @@ export const useGenerationStore = create((set, get) => ({ status: 'generating', progress: 0, createdAt: Date.now(), + isFavorited: false, }; set((s) => ({ tasks: [...s.tasks, placeholderTask] })); @@ -511,6 +516,23 @@ export const useGenerationStore = create((set, get) => ({ } }, + toggleFavorite: async (id) => { + const task = get().tasks.find((t) => t.id === id); + if (!task?.taskId) return; + // Optimistic update + set((s) => ({ + tasks: s.tasks.map((t) => t.id === id ? { ...t, isFavorited: !t.isFavorited } : t), + })); + try { + await videoApi.toggleFavorite(task.taskId); + } catch { + // Revert on failure + set((s) => ({ + tasks: s.tasks.map((t) => t.id === id ? { ...t, isFavorited: !t.isFavorited } : t), + })); + } + }, + reEdit: (id) => { const task = get().tasks.find((t) => t.id === id); if (!task) return; diff --git a/web/src/types/index.ts b/web/src/types/index.ts index dea4d59..dc58c50 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -49,6 +49,7 @@ export interface GenerationTask { createdAt: number; tokensConsumed?: number; costAmount?: number; + isFavorited?: boolean; } export interface BackendTask { @@ -68,6 +69,7 @@ export interface BackendTask { result_url: string; error_message: string; reference_urls: { url: string; type: string; role: string; label: string }[]; + is_favorited: boolean; created_at: string; }