fix: v0.18.1 用户测试 8 项 Bug 修复
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
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:
parent
d73175b101
commit
5da67435b2
@ -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_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 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
|
||||||
|
|||||||
@ -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': '素材已删除'})
|
||||||
|
|||||||
@ -363,6 +363,20 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
|||||||
>
|
>
|
||||||
✎ 改名
|
✎ 改名
|
||||||
</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 && (
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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]++);
|
||||||
|
|||||||
@ -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 }) =>
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user