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:
seaislee1209 2026-03-22 21:36:20 +08:00
parent afcff9455f
commit c381784207
17 changed files with 169 additions and 22 deletions

View File

@ -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='已收藏'),
),
]

View File

@ -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:

View File

@ -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/<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
path('announcement', views.announcement_view, name='announcement'),

View File

@ -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/<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
# ──────────────────────────────────────────────

View File

@ -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;
}

View File

@ -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<HTMLVideoElement>(null);
const moreRef = useRef<HTMLDivElement>(null);
@ -450,6 +451,11 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
<button className={styles.downloadBtn} onClick={handleDownload}>
<DownloadIcon />
</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>

View File

@ -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 (
<div className={styles.wrapper}>

View File

@ -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);

View File

@ -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;

View File

@ -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<HTMLVideoElement>(null);
const videoContainerRef = useRef<HTMLDivElement>(null);
@ -438,8 +439,12 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
</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">
<button
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" />
</svg>
</button>

View File

@ -23,7 +23,9 @@ export function VideoGenerationPage() {
const initialLoadRef = useRef(true);
const savedScrollTop = useGenerationStore((s) => s.savedScrollTop);
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)
useEffect(() => {
@ -149,6 +151,7 @@ export function VideoGenerationPage() {
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])}

View File

@ -155,6 +155,9 @@ export const videoApi = {
deleteTask: (taskId: string) =>
api.delete(`/video/tasks/${taskId}`),
toggleFavorite: (taskId: string) =>
api.post<{ is_favorited: boolean }>(`/video/tasks/${taskId}/favorite`),
getAnnouncement: () =>
api.get<{ announcement: string; enabled: boolean }>('/announcement'),
};

View File

@ -236,6 +236,18 @@ export function AdminAssetsPage() {
task={detailTask}
onClose={() => setDetailTask(null)}
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>
);

View File

@ -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<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);
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 (
<div className={styles.layout}>
@ -139,16 +148,16 @@ export function AssetsPage() {
<span className={`${styles.tab} ${styles.tabActive}`}></span>
</div>
<div className={styles.subTabs}>
<span className={`${styles.subTab} ${styles.subTabActive}`}></span>
<span className={styles.subTab}></span>
<span className={`${styles.subTab} ${subTab === 'all' ? styles.subTabActive : ''}`} onClick={() => setSubTab('all')} style={{ cursor: 'pointer' }}></span>
<span className={`${styles.subTab} ${subTab === 'favorites' ? styles.subTabActive : ''}`} onClick={() => setSubTab('favorites')} style={{ cursor: 'pointer' }}></span>
</div>
</div>
{/* Video grid by date */}
<div className={styles.content}>
{completedTasks.length === 0 ? (
{displayTasks.length === 0 ? (
<div className={styles.empty}>
<p></p>
<p>{subTab === 'favorites' ? '暂无收藏的视频' : '暂无已完成的视频'}</p>
</div>
) : (
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])}
/>
<ConfirmModal

View File

@ -183,6 +183,18 @@ export function TeamAssetsPage() {
<VideoDetailModal
task={detailTask}
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>
);

View File

@ -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<string | null>;
removeTask: (id: string) => void;
toggleFavorite: (id: string) => Promise<void>;
reEdit: (id: string) => void;
regenerate: (id: string) => void;
loadTasks: () => Promise<void>;
@ -368,6 +372,7 @@ export const useGenerationStore = create<GenerationState>((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<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) => {
const task = get().tasks.find((t) => t.id === id);
if (!task) return;

View File

@ -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;
}