fix: v0.18.1 用户测试 8 项 Bug 修复
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

- 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) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-04-04 21:06:02 +08:00
parent d73175b101
commit 5da67435b2
7 changed files with 119 additions and 23 deletions

View File

@ -12,8 +12,26 @@ def poll_video_task(record_id):
"""Poll Volcano API once for a video generation task. """Poll Volcano API once for a video generation task.
一次性任务查一次 API更新 DB结束 一次性任务查一次 API更新 DB结束
recover_stuck_tasksbeat 每30秒调度统一驱动不再自己 retry recover_stuck_tasksbeat 每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 django.utils import timezone
from apps.generation.models import GenerationRecord from apps.generation.models import GenerationRecord
from utils.airdrama_client import query_task, map_status from utils.airdrama_client import query_task, map_status

View File

@ -3141,12 +3141,13 @@ def asset_groups_view(request):
}, status=status.HTTP_201_CREATED) }, status=status.HTTP_201_CREATED)
@api_view(['GET', 'PUT']) @api_view(['GET', 'PUT', 'DELETE'])
@permission_classes([IsTeamMember]) @permission_classes([IsTeamMember])
@parser_classes([JSONParser]) @parser_classes([JSONParser])
def asset_group_detail_view(request, group_id): def asset_group_detail_view(request, group_id):
"""GET /api/v1/assets/groups/<id> — group info + assets. """GET /api/v1/assets/groups/<id> — group info + assets.
PUT /api/v1/assets/groups/<id> update name/description. PUT /api/v1/assets/groups/<id> update name/description.
DELETE /api/v1/assets/groups/<id> delete entire group + all assets.
""" """
team = request.user.team team = request.user.team
try: try:
@ -3154,6 +3155,20 @@ def asset_group_detail_view(request, group_id):
except AssetGroup.DoesNotExist: except AssetGroup.DoesNotExist:
return Response({'error': '素材组不存在'}, status=status.HTTP_404_NOT_FOUND) 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 request.method == 'GET':
# 同步火山端的素材组名字 # 同步火山端的素材组名字
if group.remote_group_id: if group.remote_group_id:
@ -3354,15 +3369,17 @@ def asset_update_view(request, asset_id):
group = asset.group group = asset.group
asset.delete() asset.delete()
# Update group thumbnail if needed # Update group thumbnail: prefer Image > Video (with thumbnail) > empty
remaining = Asset.objects.filter(group=group).exclude(status='failed').order_by('-created_at').first() remaining_img = Asset.objects.filter(group=group, asset_type='Image').exclude(status='failed').first()
if remaining: remaining_vid = Asset.objects.filter(group=group, asset_type='Video').exclude(status='failed').exclude(thumbnail_url='').first()
new_thumb = remaining.thumbnail_url or remaining.url if remaining_img:
if group.thumbnail_url != new_thumb: new_thumb = remaining_img.url
group.thumbnail_url = new_thumb elif remaining_vid:
group.save(update_fields=['thumbnail_url']) new_thumb = remaining_vid.thumbnail_url
else: else:
group.thumbnail_url = '' new_thumb = ''
if group.thumbnail_url != new_thumb:
group.thumbnail_url = new_thumb
group.save(update_fields=['thumbnail_url']) group.save(update_fields=['thumbnail_url'])
return Response({'message': '素材已删除'}) return Response({'message': '素材已删除'})

View File

@ -363,6 +363,20 @@ export function AssetLibraryModal({ open, onClose }: Props) {
> >
&#9998; &#9998;
</button> </button>
<button
className={styles.actionBtnOutline}
style={{ color: '#ef4444', borderColor: '#ef4444' }}
onClick={() => {
if (confirm('确认删除整个素材组?组内所有素材将被删除,此操作不可撤销。')) {
assetsApi.deleteGroup(selectedGroup.id).then(() => {
showToast('素材组已删除');
handleBackToList();
}).catch(() => showToast('删除失败,请重试'));
}
}}
>
</button>
</div> </div>
{editingName && editingName.id === selectedGroup.id && ( {editingName && editingName.id === selectedGroup.id && (

View File

@ -37,10 +37,11 @@ const DownloadIcon = () => (
); );
// Mention tag with thumbnail + hover preview // 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 [hover, setHover] = useState(false);
const ref = useRef<HTMLSpanElement>(null); const ref = useRef<HTMLSpanElement>(null);
const [pos, setPos] = useState({ top: 0, left: 0 }); const [pos, setPos] = useState({ top: 0, left: 0 });
const isAudio = assetType === 'Audio' || assetType === 'audio';
return ( return (
<> <>
@ -48,7 +49,7 @@ function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) {
ref={ref} ref={ref}
className={styles.mentionTag} className={styles.mentionTag}
onMouseEnter={() => { onMouseEnter={() => {
if (thumbUrl && ref.current) { if (!isAudio && thumbUrl && ref.current) {
const rect = ref.current.getBoundingClientRect(); const rect = ref.current.getBoundingClientRect();
setPos({ top: rect.top - 8, left: rect.left + rect.width / 2 }); setPos({ top: rect.top - 8, left: rect.left + rect.width / 2 });
setHover(true); setHover(true);
@ -56,13 +57,15 @@ function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) {
}} }}
onMouseLeave={() => setHover(false)} onMouseLeave={() => setHover(false)}
> >
{thumbUrl && ( {isAudio ? (
<span style={{ marginRight: 3, fontSize: 13, verticalAlign: 'middle' }}></span>
) : thumbUrl ? (
<img <img
src={tosThumb(thumbUrl, 28)} src={tosThumb(thumbUrl, 28)}
alt="" alt=""
style={{ width: 14, height: 14, borderRadius: 3, objectFit: 'cover', verticalAlign: 'middle', marginRight: 3 }} style={{ width: 14, height: 14, borderRadius: 3, objectFit: 'cover', verticalAlign: 'middle', marginRight: 3 }}
/> />
)} ) : null}
{label} {label}
</span> </span>
{hover && thumbUrl && createPortal( {hover && thumbUrl && createPortal(
@ -82,13 +85,19 @@ export function renderPromptWithMentions(
assetMentions: Record<string, unknown>[], assetMentions: Record<string, unknown>[],
references: { label: string; previewUrl?: string }[] references: { label: string; previewUrl?: string }[]
) { ) {
// Build lookup: label → thumbUrl // Build lookup: label → { thumbUrl, assetType }
const thumbMap = new Map<string, string>(); const thumbMap = new Map<string, { thumbUrl: string; assetType: string }>();
for (const am of assetMentions) { 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) { 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<string, unknown>).type as string || 'image',
});
} }
const labels = [...thumbMap.keys()]; const labels = [...thumbMap.keys()];
@ -106,7 +115,8 @@ export function renderPromptWithMentions(
if (regex.test(part)) { if (regex.test(part)) {
regex.lastIndex = 0; regex.lastIndex = 0;
const label = part.slice(1); // remove @ const label = part.slice(1); // remove @
return <MentionTag key={i} label={label} thumbUrl={thumbMap.get(label)} />; const info = thumbMap.get(label);
return <MentionTag key={i} label={label} thumbUrl={info?.thumbUrl} assetType={info?.assetType} />;
} }
regex.lastIndex = 0; regex.lastIndex = 0;
return part; return part;

View File

@ -3,7 +3,7 @@ import DOMPurify from 'dompurify';
import { useInputBarStore } from '../store/inputBar'; import { useInputBarStore } from '../store/inputBar';
import { assetsApi, tosThumb } from '../lib/api'; import { assetsApi, tosThumb } from '../lib/api';
import type { UploadedFile, AssetSearchResult } from '../types'; import type { UploadedFile, AssetSearchResult } from '../types';
import { parseAssetMentions } from '../lib/assetMentions'; import { parseAssetMentionsFromDOM } from '../lib/assetMentions';
import { showToast } from './Toast'; import { showToast } from './Toast';
import styles from './PromptInput.module.css'; import styles from './PromptInput.module.css';
@ -143,6 +143,10 @@ export function PromptInput() {
if (targets.length === 0) return; 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 walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const replacements: { node: Text; matches: { start: number; end: number; target: MatchTarget }[] }[] = []; const replacements: { node: Text; matches: { start: number; end: number; target: MatchTarget }[] }[] = [];
@ -272,6 +276,16 @@ export function PromptInput() {
} }
}, [references, extractText]); }, [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(() => { const handleInput = useCallback(() => {
extractText(); extractText();
@ -381,7 +395,7 @@ export function PromptInput() {
const insertAssetMention = useCallback((asset: AssetSearchResult) => { const insertAssetMention = useCallback((asset: AssetSearchResult) => {
// Instant check: count limit // 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 refs = useInputBarStore.getState().references;
const refCounts = { image: 0, video: 0, audio: 0 }; const refCounts = { image: 0, video: 0, audio: 0 };
refs.forEach((r) => refCounts[r.type]++); refs.forEach((r) => refCounts[r.type]++);

View File

@ -420,6 +420,8 @@ export const assetsApi = {
api.get<AssetGroup & { assets: AssetItem[] }>(`/assets/groups/${id}`), api.get<AssetGroup & { assets: AssetItem[] }>(`/assets/groups/${id}`),
updateGroup: (id: number, data: { name?: string; description?: string }) => updateGroup: (id: number, data: { name?: string; description?: string }) =>
api.put(`/assets/groups/${id}`, data), api.put(`/assets/groups/${id}`, data),
deleteGroup: (id: number) =>
api.delete(`/assets/groups/${id}`),
addAsset: (groupId: number, data: FormData) => addAsset: (groupId: number, data: FormData) =>
api.post<AssetItem>(`/assets/groups/${groupId}/assets`, data, { headers: { 'Content-Type': 'multipart/form-data' } }), api.post<AssetItem>(`/assets/groups/${groupId}/assets`, data, { headers: { 'Content-Type': 'multipart/form-data' } }),
updateAsset: (id: number, data: { name: string }) => updateAsset: (id: number, data: { name: string }) =>

View File

@ -1,6 +1,27 @@
/** /**
* Parse asset mention spans from editor HTML. * Parse asset mention spans directly from a DOM element (real-time, no stale state).
* Returns counts and durations by type, used for number/duration limit checks. * 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): { export function parseAssetMentions(html: string): {
counts: { image: number; video: number; audio: number }; counts: { image: number; video: number; audio: number };