From 5da67435b2ece27750ea77905267be77f244a140 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sat, 4 Apr 2026 21:06:02 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20v0.18.1=20=E7=94=A8=E6=88=B7=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=208=20=E9=A1=B9=20Bug=20=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MutationObserver 立刻同步 editorHtml(删 @ 标签后时长/数量立即重置) - parseAssetMentionsFromDOM 从 DOM 实时读取(不用 stale state) - renderPromptWithMentions 支持音频 ♫ + 视频首帧 + assetType - rebuildMentionSpans 按 label 长度降序匹配(防子串冲突) - 删除素材后 group 缩略图优先找图片/视频(不用音频 URL) - 素材组整组删除功能(后端 DELETE + 前端按钮) - Celery poll 架构重构(一次性任务 + recover_stuck_tasks 统一驱动) Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/apps/generation/tasks.py | 20 +++++++++++++- backend/apps/generation/views.py | 35 ++++++++++++++++++------ web/src/components/AssetLibraryModal.tsx | 14 ++++++++++ web/src/components/GenerationCard.tsx | 28 +++++++++++++------ web/src/components/PromptInput.tsx | 18 ++++++++++-- web/src/lib/api.ts | 2 ++ web/src/lib/assetMentions.ts | 25 +++++++++++++++-- 7 files changed, 119 insertions(+), 23 deletions(-) diff --git a/backend/apps/generation/tasks.py b/backend/apps/generation/tasks.py index 8f98f1f..e7311c4 100644 --- a/backend/apps/generation/tasks.py +++ b/backend/apps/generation/tasks.py @@ -12,8 +12,26 @@ def poll_video_task(record_id): """Poll Volcano API once for a video generation task. 一次性任务:查一次 API,更新 DB,结束。 - 由 recover_stuck_tasks(beat 每30秒调度)统一驱动,不再自己 retry。 + 由 recover_stuck_tasks(beat 每10秒调度)统一驱动,不再自己 retry。 + 用 Redis 锁防止 _handle_completed 期间被重复 dispatch。 """ + from django.core.cache import cache + + # Redis 锁:防止同一 record 被并发处理(_handle_completed 耗时较长) + lock_key = f'poll_lock:{record_id}' + if not cache.add(lock_key, '1', timeout=120): + return + + try: + _do_poll(record_id) + except Exception: + logger.exception('poll_video_task: unexpected error for record=%s', record_id) + finally: + cache.delete(lock_key) + + +def _do_poll(record_id): + """实际轮询逻辑,由 poll_video_task 调用。""" from django.utils import timezone from apps.generation.models import GenerationRecord from utils.airdrama_client import query_task, map_status diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index 952c52f..cbe718f 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -3141,12 +3141,13 @@ def asset_groups_view(request): }, status=status.HTTP_201_CREATED) -@api_view(['GET', 'PUT']) +@api_view(['GET', 'PUT', 'DELETE']) @permission_classes([IsTeamMember]) @parser_classes([JSONParser]) def asset_group_detail_view(request, group_id): """GET /api/v1/assets/groups/ — group info + assets. PUT /api/v1/assets/groups/ — update name/description. + DELETE /api/v1/assets/groups/ — delete entire group + all assets. """ team = request.user.team try: @@ -3154,6 +3155,20 @@ def asset_group_detail_view(request, group_id): except AssetGroup.DoesNotExist: return Response({'error': '素材组不存在'}, status=status.HTTP_404_NOT_FOUND) + if request.method == 'DELETE': + # Delete all remote assets in this group + from utils import assets_client + for asset in Asset.objects.filter(group=group): + if asset.remote_asset_id: + try: + assets_client.delete_asset(asset.remote_asset_id) + except Exception as e: + logger.warning('Failed to delete remote asset %s: %s', asset.remote_asset_id, e) + # Delete local records + Asset.objects.filter(group=group).delete() + group.delete() + return Response({'message': '素材组已删除'}) + if request.method == 'GET': # 同步火山端的素材组名字 if group.remote_group_id: @@ -3354,15 +3369,17 @@ def asset_update_view(request, asset_id): group = asset.group asset.delete() - # Update group thumbnail if needed - remaining = Asset.objects.filter(group=group).exclude(status='failed').order_by('-created_at').first() - if remaining: - new_thumb = remaining.thumbnail_url or remaining.url - if group.thumbnail_url != new_thumb: - group.thumbnail_url = new_thumb - group.save(update_fields=['thumbnail_url']) + # Update group thumbnail: prefer Image > Video (with thumbnail) > empty + remaining_img = Asset.objects.filter(group=group, asset_type='Image').exclude(status='failed').first() + remaining_vid = Asset.objects.filter(group=group, asset_type='Video').exclude(status='failed').exclude(thumbnail_url='').first() + if remaining_img: + new_thumb = remaining_img.url + elif remaining_vid: + new_thumb = remaining_vid.thumbnail_url else: - group.thumbnail_url = '' + new_thumb = '' + if group.thumbnail_url != new_thumb: + group.thumbnail_url = new_thumb group.save(update_fields=['thumbnail_url']) return Response({'message': '素材已删除'}) diff --git a/web/src/components/AssetLibraryModal.tsx b/web/src/components/AssetLibraryModal.tsx index c42a62a..f769ad0 100644 --- a/web/src/components/AssetLibraryModal.tsx +++ b/web/src/components/AssetLibraryModal.tsx @@ -363,6 +363,20 @@ export function AssetLibraryModal({ open, onClose }: Props) { > ✎ 改名 + {editingName && editingName.id === selectedGroup.id && ( diff --git a/web/src/components/GenerationCard.tsx b/web/src/components/GenerationCard.tsx index 9d2afa0..8e3a3e8 100644 --- a/web/src/components/GenerationCard.tsx +++ b/web/src/components/GenerationCard.tsx @@ -37,10 +37,11 @@ const DownloadIcon = () => ( ); // Mention tag with thumbnail + hover preview -function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) { +function MentionTag({ label, thumbUrl, assetType }: { label: string; thumbUrl?: string; assetType?: string }) { const [hover, setHover] = useState(false); const ref = useRef(null); const [pos, setPos] = useState({ top: 0, left: 0 }); + const isAudio = assetType === 'Audio' || assetType === 'audio'; return ( <> @@ -48,7 +49,7 @@ function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) { ref={ref} className={styles.mentionTag} onMouseEnter={() => { - if (thumbUrl && ref.current) { + if (!isAudio && thumbUrl && ref.current) { const rect = ref.current.getBoundingClientRect(); setPos({ top: rect.top - 8, left: rect.left + rect.width / 2 }); setHover(true); @@ -56,13 +57,15 @@ function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) { }} onMouseLeave={() => setHover(false)} > - {thumbUrl && ( + {isAudio ? ( + + ) : thumbUrl ? ( - )} + ) : null} {label} {hover && thumbUrl && createPortal( @@ -82,13 +85,19 @@ export function renderPromptWithMentions( assetMentions: Record[], references: { label: string; previewUrl?: string }[] ) { - // Build lookup: label → thumbUrl - const thumbMap = new Map(); + // Build lookup: label → { thumbUrl, assetType } + const thumbMap = new Map(); for (const am of assetMentions) { - if (am.label) thumbMap.set(am.label as string, (am.thumbUrl as string) || ''); + if (am.label) thumbMap.set(am.label as string, { + thumbUrl: (am.thumbUrl as string) || '', + assetType: (am.assetType as string) || 'image', + }); } for (const r of references) { - if (r.label && !thumbMap.has(r.label)) thumbMap.set(r.label, r.previewUrl || ''); + if (r.label && !thumbMap.has(r.label)) thumbMap.set(r.label, { + thumbUrl: r.previewUrl || '', + assetType: (r as Record).type as string || 'image', + }); } const labels = [...thumbMap.keys()]; @@ -106,7 +115,8 @@ export function renderPromptWithMentions( if (regex.test(part)) { regex.lastIndex = 0; const label = part.slice(1); // remove @ - return ; + const info = thumbMap.get(label); + return ; } regex.lastIndex = 0; return part; diff --git a/web/src/components/PromptInput.tsx b/web/src/components/PromptInput.tsx index c285697..21d1ea0 100644 --- a/web/src/components/PromptInput.tsx +++ b/web/src/components/PromptInput.tsx @@ -3,7 +3,7 @@ import DOMPurify from 'dompurify'; import { useInputBarStore } from '../store/inputBar'; import { assetsApi, tosThumb } from '../lib/api'; import type { UploadedFile, AssetSearchResult } from '../types'; -import { parseAssetMentions } from '../lib/assetMentions'; +import { parseAssetMentionsFromDOM } from '../lib/assetMentions'; import { showToast } from './Toast'; import styles from './PromptInput.module.css'; @@ -143,6 +143,10 @@ export function PromptInput() { if (targets.length === 0) return; + // Sort targets by label length descending — longer labels match first + // Prevents "苏晓雨" from stealing the match before "苏晓雨音频" + targets.sort((a, b) => b.label.length - a.label.length); + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); const replacements: { node: Text; matches: { start: number; end: number; target: MatchTarget }[] }[] = []; @@ -272,6 +276,16 @@ export function PromptInput() { } }, [references, extractText]); + // Sync editorHtml immediately on ANY DOM change (backspace delete, etc.) + // Without this, deleting a mention span doesn't update editorHtml until next input event + useEffect(() => { + const el = editorRef.current; + if (!el) return; + const observer = new MutationObserver(() => extractText()); + observer.observe(el, { childList: true, subtree: true, characterData: true }); + return () => observer.disconnect(); + }, [extractText]); + const handleInput = useCallback(() => { extractText(); @@ -381,7 +395,7 @@ export function PromptInput() { const insertAssetMention = useCallback((asset: AssetSearchResult) => { // Instant check: count limit - const stats = parseAssetMentions(editorHtml); + const stats = editorRef.current ? parseAssetMentionsFromDOM(editorRef.current) : { counts: { image: 0, video: 0, audio: 0 }, durations: { video: 0, audio: 0 } }; const refs = useInputBarStore.getState().references; const refCounts = { image: 0, video: 0, audio: 0 }; refs.forEach((r) => refCounts[r.type]++); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 3636cb3..d9d52b7 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -420,6 +420,8 @@ export const assetsApi = { api.get(`/assets/groups/${id}`), updateGroup: (id: number, data: { name?: string; description?: string }) => api.put(`/assets/groups/${id}`, data), + deleteGroup: (id: number) => + api.delete(`/assets/groups/${id}`), addAsset: (groupId: number, data: FormData) => api.post(`/assets/groups/${groupId}/assets`, data, { headers: { 'Content-Type': 'multipart/form-data' } }), updateAsset: (id: number, data: { name: string }) => diff --git a/web/src/lib/assetMentions.ts b/web/src/lib/assetMentions.ts index dd68232..46c04bf 100644 --- a/web/src/lib/assetMentions.ts +++ b/web/src/lib/assetMentions.ts @@ -1,6 +1,27 @@ /** - * Parse asset mention spans from editor HTML. - * Returns counts and durations by type, used for number/duration limit checks. + * Parse asset mention spans directly from a DOM element (real-time, no stale state). + * Use this when you have access to the editor DOM element. + */ +export function parseAssetMentionsFromDOM(el: HTMLElement): { + counts: { image: number; video: number; audio: number }; + durations: { video: number; audio: number }; +} { + const counts = { image: 0, video: 0, audio: 0 }; + const durations = { video: 0, audio: 0 }; + el.querySelectorAll('[data-ref-type="asset"]').forEach((span) => { + const t = (span as HTMLElement).dataset.assetType || 'Image'; + const rawDur = parseFloat((span as HTMLElement).dataset.duration || '0'); + const dur = isNaN(rawDur) ? 0 : rawDur; + if (t === 'Video') { counts.video++; durations.video += dur; } + else if (t === 'Audio') { counts.audio++; durations.audio += dur; } + else { counts.image++; } + }); + return { counts, durations }; +} + +/** + * Parse asset mention spans from editor HTML string. + * Use this when you only have the HTML string (e.g., from store state). */ export function parseAssetMentions(html: string): { counts: { image: number; video: number; audio: number };