feat: v0.12.2 收藏功能 + UI 修复
①视频收藏(is_favorited + toggle API + 卡片/详情页收藏按钮 + 资产页「我的收藏」筛选) ②联网搜索按钮永久禁用(待开放) ③音频标签加音符符号,hover 不弹预览 ④轮询完成后自动更新 token/费用(不用刷新页面) ⑤超管/团管内容资产页视频详情加上下切换箭头 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
afcff9455f
commit
c381784207
@ -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='已收藏'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -44,6 +44,7 @@ class GenerationRecord(models.Model):
|
|||||||
result_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='生成结果URL')
|
result_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='生成结果URL')
|
||||||
error_message = models.TextField(blank=True, default='', verbose_name='错误信息')
|
error_message = models.TextField(blank=True, default='', verbose_name='错误信息')
|
||||||
reference_urls = models.JSONField(default=list, blank=True, 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='创建时间')
|
created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@ -8,6 +8,7 @@ urlpatterns = [
|
|||||||
path('video/generate', views.video_generate_view, name='video_generate'),
|
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_tasks_list_view, name='video_tasks_list'),
|
||||||
path('video/tasks/<uuid:task_id>', views.video_task_detail_view, name='video_task_detail'),
|
path('video/tasks/<uuid:task_id>', views.video_task_detail_view, name='video_task_detail'),
|
||||||
|
path('video/tasks/<uuid:task_id>/favorite', views.video_task_toggle_favorite_view, name='video_task_toggle_favorite'),
|
||||||
# Public announcement
|
# Public announcement
|
||||||
path('announcement', views.announcement_view, name='announcement'),
|
path('announcement', views.announcement_view, name='announcement'),
|
||||||
|
|
||||||
|
|||||||
@ -540,10 +540,25 @@ def _serialize_task(record):
|
|||||||
'result_url': d.get('result_url', ''),
|
'result_url': d.get('result_url', ''),
|
||||||
'error_message': d.get('error_message', ''),
|
'error_message': d.get('error_message', ''),
|
||||||
'reference_urls': d.get('reference_urls') or [],
|
'reference_urls': d.get('reference_urls') or [],
|
||||||
|
'is_favorited': record.is_favorited,
|
||||||
'created_at': record.created_at.isoformat(),
|
'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/<task_id>/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
|
# Admin: Dashboard Stats
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|||||||
@ -236,8 +236,10 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
flex-direction: column;
|
||||||
justify-content: flex-end;
|
align-items: flex-end;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
animation: overlayFadeIn 0.15s ease-out;
|
animation: overlayFadeIn 0.15s ease-out;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -122,6 +122,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
|||||||
const removeTask = useGenerationStore((s) => s.removeTask);
|
const removeTask = useGenerationStore((s) => s.removeTask);
|
||||||
const reEdit = useGenerationStore((s) => s.reEdit);
|
const reEdit = useGenerationStore((s) => s.reEdit);
|
||||||
const regenerate = useGenerationStore((s) => s.regenerate);
|
const regenerate = useGenerationStore((s) => s.regenerate);
|
||||||
|
const toggleFavorite = useGenerationStore((s) => s.toggleFavorite);
|
||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const moreRef = useRef<HTMLDivElement>(null);
|
const moreRef = useRef<HTMLDivElement>(null);
|
||||||
@ -450,6 +451,11 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
|||||||
<button className={styles.downloadBtn} onClick={handleDownload}>
|
<button className={styles.downloadBtn} onClick={handleDownload}>
|
||||||
<DownloadIcon />
|
<DownloadIcon />
|
||||||
</button>
|
</button>
|
||||||
|
<button className={styles.downloadBtn} onClick={(e) => { e.stopPropagation(); toggleFavorite(task.id); }}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill={task.isFavorited ? '#faad14' : 'none'} stroke={task.isFavorited ? '#faad14' : '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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -81,9 +81,8 @@ export function InputBar() {
|
|||||||
const firstFrame = useInputBarStore((s) => s.firstFrame);
|
const firstFrame = useInputBarStore((s) => s.firstFrame);
|
||||||
const lastFrame = useInputBarStore((s) => s.lastFrame);
|
const lastFrame = useInputBarStore((s) => s.lastFrame);
|
||||||
|
|
||||||
// 联网搜索仅支持纯文生视频(无参考图/视频/音频/素材)
|
// 联网搜索暂未开放
|
||||||
const hasMedia = references.length > 0 || !!firstFrame || !!lastFrame || editorHtml.includes('data-ref-type="asset"');
|
const searchDisabled = true;
|
||||||
const searchDisabled = hasMedia;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
|
|||||||
@ -75,11 +75,15 @@ export function PromptInput() {
|
|||||||
if (opts.assetGroupId) span.dataset.assetGroupId = opts.assetGroupId;
|
if (opts.assetGroupId) span.dataset.assetGroupId = opts.assetGroupId;
|
||||||
if (opts.groupName) span.dataset.groupName = opts.groupName;
|
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');
|
const img = document.createElement('img');
|
||||||
img.src = tosThumb(opts.thumbUrl, 32);
|
img.src = tosThumb(opts.thumbUrl, 32);
|
||||||
img.className = styles.mentionImg;
|
img.className = styles.mentionImg;
|
||||||
// 显式设置尺寸,防止 CSS class 未生效时图片为 0x0
|
|
||||||
img.setAttribute('width', '16');
|
img.setAttribute('width', '16');
|
||||||
img.setAttribute('height', '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';
|
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 refId = target.dataset.refId;
|
||||||
const refType = target.dataset.refType;
|
const refType = target.dataset.refType;
|
||||||
|
|
||||||
|
// 音频标签不显示 hover 预览
|
||||||
|
if (refType === 'audio') return;
|
||||||
|
|
||||||
// 参考图:从 references 中查找
|
// 参考图:从 references 中查找
|
||||||
let found = references.find((r) => r.id === refId);
|
let found = references.find((r) => r.id === refId);
|
||||||
|
|
||||||
|
|||||||
@ -60,6 +60,35 @@
|
|||||||
background: rgba(255, 255, 255, 0.12);
|
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 */
|
/* Video area — centres the player */
|
||||||
.videoArea {
|
.videoArea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@ -15,6 +15,7 @@ interface Props {
|
|||||||
onReEdit?: (id: string) => void;
|
onReEdit?: (id: string) => void;
|
||||||
onRegenerate?: (id: string) => void;
|
onRegenerate?: (id: string) => void;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
|
onToggleFavorite?: (id: string) => void;
|
||||||
hideReEdit?: boolean;
|
hideReEdit?: boolean;
|
||||||
onPrev?: () => void;
|
onPrev?: () => void;
|
||||||
onNext?: () => void;
|
onNext?: () => void;
|
||||||
@ -22,7 +23,7 @@ interface Props {
|
|||||||
hasNext?: boolean;
|
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 navigate = useNavigate();
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const videoContainerRef = useRef<HTMLDivElement>(null);
|
const videoContainerRef = useRef<HTMLDivElement>(null);
|
||||||
@ -438,8 +439,12 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
|||||||
下载
|
下载
|
||||||
</button>
|
</button>
|
||||||
<div className={styles.headerIcons}>
|
<div className={styles.headerIcons}>
|
||||||
<button className={styles.iconBtn} title="收藏">
|
<button
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
className={styles.iconBtn}
|
||||||
|
title={task.isFavorited ? '取消收藏' : '收藏'}
|
||||||
|
onClick={() => task && onToggleFavorite?.(task.id)}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill={task.isFavorited ? '#faad14' : 'none'} stroke={task.isFavorited ? '#faad14' : '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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -23,7 +23,9 @@ export function VideoGenerationPage() {
|
|||||||
const initialLoadRef = useRef(true);
|
const initialLoadRef = useRef(true);
|
||||||
const savedScrollTop = useGenerationStore((s) => s.savedScrollTop);
|
const savedScrollTop = useGenerationStore((s) => s.savedScrollTop);
|
||||||
const saveScrollPosition = useGenerationStore((s) => s.saveScrollPosition);
|
const saveScrollPosition = useGenerationStore((s) => s.saveScrollPosition);
|
||||||
const [detailTask, setDetailTask] = useState<GenerationTask | null>(null);
|
const [detailTaskId, setDetailTaskId] = useState<string | null>(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), []);
|
||||||
|
|
||||||
// Load tasks from backend on mount (persist across page refresh)
|
// Load tasks from backend on mount (persist across page refresh)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -149,6 +151,7 @@ export function VideoGenerationPage() {
|
|||||||
onReEdit={handleReEdit}
|
onReEdit={handleReEdit}
|
||||||
onRegenerate={handleRegenerate}
|
onRegenerate={handleRegenerate}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onToggleFavorite={(id) => { useGenerationStore.getState().toggleFavorite(id); }}
|
||||||
hasPrev={detailIdx > 0}
|
hasPrev={detailIdx > 0}
|
||||||
hasNext={detailIdx >= 0 && detailIdx < completedTasks.length - 1}
|
hasNext={detailIdx >= 0 && detailIdx < completedTasks.length - 1}
|
||||||
onPrev={() => detailIdx > 0 && setDetailTask(completedTasks[detailIdx - 1])}
|
onPrev={() => detailIdx > 0 && setDetailTask(completedTasks[detailIdx - 1])}
|
||||||
|
|||||||
@ -155,6 +155,9 @@ export const videoApi = {
|
|||||||
deleteTask: (taskId: string) =>
|
deleteTask: (taskId: string) =>
|
||||||
api.delete(`/video/tasks/${taskId}`),
|
api.delete(`/video/tasks/${taskId}`),
|
||||||
|
|
||||||
|
toggleFavorite: (taskId: string) =>
|
||||||
|
api.post<{ is_favorited: boolean }>(`/video/tasks/${taskId}/favorite`),
|
||||||
|
|
||||||
getAnnouncement: () =>
|
getAnnouncement: () =>
|
||||||
api.get<{ announcement: string; enabled: boolean }>('/announcement'),
|
api.get<{ announcement: string; enabled: boolean }>('/announcement'),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -236,6 +236,18 @@ export function AdminAssetsPage() {
|
|||||||
task={detailTask}
|
task={detailTask}
|
||||||
onClose={() => setDetailTask(null)}
|
onClose={() => setDetailTask(null)}
|
||||||
hideReEdit
|
hideReEdit
|
||||||
|
{...(() => {
|
||||||
|
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])),
|
||||||
|
};
|
||||||
|
})()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 { Sidebar } from '../components/Sidebar';
|
||||||
import { VideoDetailModal } from '../components/VideoDetailModal';
|
import { VideoDetailModal } from '../components/VideoDetailModal';
|
||||||
import { useGenerationStore } from '../store/generation';
|
import { useGenerationStore } from '../store/generation';
|
||||||
@ -91,19 +91,28 @@ export function AssetsPage() {
|
|||||||
const reEdit = useGenerationStore((s) => s.reEdit);
|
const reEdit = useGenerationStore((s) => s.reEdit);
|
||||||
const regenerate = useGenerationStore((s) => s.regenerate);
|
const regenerate = useGenerationStore((s) => s.regenerate);
|
||||||
const removeTask = useGenerationStore((s) => s.removeTask);
|
const removeTask = useGenerationStore((s) => s.removeTask);
|
||||||
const [detailTask, setDetailTask] = useState<GenerationTask | null>(null);
|
const [detailTaskId, setDetailTaskId] = useState<string | null>(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<string | null>(null);
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTasks();
|
loadTasks();
|
||||||
}, [loadTasks]);
|
}, [loadTasks]);
|
||||||
|
|
||||||
|
const [subTab, setSubTab] = useState<'all' | 'favorites'>('all');
|
||||||
|
|
||||||
const completedTasks = useMemo(
|
const completedTasks = useMemo(
|
||||||
() => tasks.filter((t) => t.status === 'completed'),
|
() => tasks.filter((t) => t.status === 'completed'),
|
||||||
[tasks],
|
[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) => {
|
const handleReEdit = (id: string) => {
|
||||||
reEdit(id);
|
reEdit(id);
|
||||||
@ -127,7 +136,7 @@ export function AssetsPage() {
|
|||||||
setConfirmDeleteId(null);
|
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 (
|
return (
|
||||||
<div className={styles.layout}>
|
<div className={styles.layout}>
|
||||||
@ -139,16 +148,16 @@ export function AssetsPage() {
|
|||||||
<span className={`${styles.tab} ${styles.tabActive}`}>视频</span>
|
<span className={`${styles.tab} ${styles.tabActive}`}>视频</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.subTabs}>
|
<div className={styles.subTabs}>
|
||||||
<span className={`${styles.subTab} ${styles.subTabActive}`}>所有视频</span>
|
<span className={`${styles.subTab} ${subTab === 'all' ? styles.subTabActive : ''}`} onClick={() => setSubTab('all')} style={{ cursor: 'pointer' }}>所有视频</span>
|
||||||
<span className={styles.subTab}>我的收藏</span>
|
<span className={`${styles.subTab} ${subTab === 'favorites' ? styles.subTabActive : ''}`} onClick={() => setSubTab('favorites')} style={{ cursor: 'pointer' }}>我的收藏</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Video grid by date */}
|
{/* Video grid by date */}
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{completedTasks.length === 0 ? (
|
{displayTasks.length === 0 ? (
|
||||||
<div className={styles.empty}>
|
<div className={styles.empty}>
|
||||||
<p>暂无已完成的视频</p>
|
<p>{subTab === 'favorites' ? '暂无收藏的视频' : '暂无已完成的视频'}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
dateGroups.map((group) => (
|
dateGroups.map((group) => (
|
||||||
@ -175,10 +184,11 @@ export function AssetsPage() {
|
|||||||
onReEdit={handleReEdit}
|
onReEdit={handleReEdit}
|
||||||
onRegenerate={handleRegenerate}
|
onRegenerate={handleRegenerate}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onToggleFavorite={(id) => { useGenerationStore.getState().toggleFavorite(id); }}
|
||||||
hasPrev={detailIdx > 0}
|
hasPrev={detailIdx > 0}
|
||||||
hasNext={detailIdx >= 0 && detailIdx < completedTasks.length - 1}
|
hasNext={detailIdx >= 0 && detailIdx < displayTasks.length - 1}
|
||||||
onPrev={() => detailIdx > 0 && setDetailTask(completedTasks[detailIdx - 1])}
|
onPrev={() => detailIdx > 0 && setDetailTask(displayTasks[detailIdx - 1])}
|
||||||
onNext={() => detailIdx < completedTasks.length - 1 && setDetailTask(completedTasks[detailIdx + 1])}
|
onNext={() => detailIdx < displayTasks.length - 1 && setDetailTask(displayTasks[detailIdx + 1])}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
|
|||||||
@ -183,6 +183,18 @@ export function TeamAssetsPage() {
|
|||||||
<VideoDetailModal
|
<VideoDetailModal
|
||||||
task={detailTask}
|
task={detailTask}
|
||||||
onClose={() => setDetailTask(null)}
|
onClose={() => 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])),
|
||||||
|
};
|
||||||
|
})()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -105,6 +105,7 @@ function backendToFrontend(bt: BackendTask): GenerationTask {
|
|||||||
createdAt: new Date(bt.created_at).getTime(),
|
createdAt: new Date(bt.created_at).getTime(),
|
||||||
tokensConsumed: bt.tokens_consumed || 0,
|
tokensConsumed: bt.tokens_consumed || 0,
|
||||||
costAmount: bt.cost_amount || 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,
|
progress: newStatus === 'completed' ? 100 : newStatus === 'failed' ? 0 : t.progress,
|
||||||
resultUrl: data.result_url || t.resultUrl,
|
resultUrl: data.result_url || t.resultUrl,
|
||||||
errorMessage: mapErrorMessage(data.error_message) || t.errorMessage,
|
errorMessage: mapErrorMessage(data.error_message) || t.errorMessage,
|
||||||
|
tokensConsumed: data.tokens_consumed ?? t.tokensConsumed,
|
||||||
|
costAmount: data.cost_amount ?? t.costAmount,
|
||||||
}
|
}
|
||||||
: t
|
: t
|
||||||
),
|
),
|
||||||
@ -208,6 +211,7 @@ interface GenerationState {
|
|||||||
savedScrollTop: number | null;
|
savedScrollTop: number | null;
|
||||||
addTask: () => Promise<string | null>;
|
addTask: () => Promise<string | null>;
|
||||||
removeTask: (id: string) => void;
|
removeTask: (id: string) => void;
|
||||||
|
toggleFavorite: (id: string) => Promise<void>;
|
||||||
reEdit: (id: string) => void;
|
reEdit: (id: string) => void;
|
||||||
regenerate: (id: string) => void;
|
regenerate: (id: string) => void;
|
||||||
loadTasks: () => Promise<void>;
|
loadTasks: () => Promise<void>;
|
||||||
@ -368,6 +372,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
|||||||
status: 'generating',
|
status: 'generating',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
isFavorited: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
set((s) => ({ tasks: [...s.tasks, placeholderTask] }));
|
set((s) => ({ tasks: [...s.tasks, placeholderTask] }));
|
||||||
@ -511,6 +516,23 @@ export const useGenerationStore = create<GenerationState>((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) => {
|
reEdit: (id) => {
|
||||||
const task = get().tasks.find((t) => t.id === id);
|
const task = get().tasks.find((t) => t.id === id);
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
|
|||||||
@ -49,6 +49,7 @@ export interface GenerationTask {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
tokensConsumed?: number;
|
tokensConsumed?: number;
|
||||||
costAmount?: number;
|
costAmount?: number;
|
||||||
|
isFavorited?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackendTask {
|
export interface BackendTask {
|
||||||
@ -68,6 +69,7 @@ export interface BackendTask {
|
|||||||
result_url: string;
|
result_url: string;
|
||||||
error_message: string;
|
error_message: string;
|
||||||
reference_urls: { url: string; type: string; role: string; label: string }[];
|
reference_urls: { url: string; type: string; role: string; label: string }[];
|
||||||
|
is_favorited: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user