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')
|
||||
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:
|
||||
|
||||
@ -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'),
|
||||
|
||||
|
||||
@ -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
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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])}
|
||||
|
||||
@ -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'),
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user