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.
|
||||
|
||||
一次性任务:查一次 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
|
||||
|
||||
@ -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/<id> — group info + assets.
|
||||
PUT /api/v1/assets/groups/<id> — update name/description.
|
||||
DELETE /api/v1/assets/groups/<id> — 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,16 +3369,18 @@ 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
|
||||
# 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:
|
||||
new_thumb = ''
|
||||
if group.thumbnail_url != new_thumb:
|
||||
group.thumbnail_url = new_thumb
|
||||
group.save(update_fields=['thumbnail_url'])
|
||||
else:
|
||||
group.thumbnail_url = ''
|
||||
group.save(update_fields=['thumbnail_url'])
|
||||
|
||||
return Response({'message': '素材已删除'})
|
||||
|
||||
|
||||
@ -363,6 +363,20 @@ export function AssetLibraryModal({ open, onClose }: Props) {
|
||||
>
|
||||
✎ 改名
|
||||
</button>
|
||||
<button
|
||||
className={styles.actionBtnOutline}
|
||||
style={{ color: '#ef4444', borderColor: '#ef4444' }}
|
||||
onClick={() => {
|
||||
if (confirm('确认删除整个素材组?组内所有素材将被删除,此操作不可撤销。')) {
|
||||
assetsApi.deleteGroup(selectedGroup.id).then(() => {
|
||||
showToast('素材组已删除');
|
||||
handleBackToList();
|
||||
}).catch(() => showToast('删除失败,请重试'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除素材组
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editingName && editingName.id === selectedGroup.id && (
|
||||
|
||||
@ -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<HTMLSpanElement>(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 ? (
|
||||
<span style={{ marginRight: 3, fontSize: 13, verticalAlign: 'middle' }}>♫</span>
|
||||
) : thumbUrl ? (
|
||||
<img
|
||||
src={tosThumb(thumbUrl, 28)}
|
||||
alt=""
|
||||
style={{ width: 14, height: 14, borderRadius: 3, objectFit: 'cover', verticalAlign: 'middle', marginRight: 3 }}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
{label}
|
||||
</span>
|
||||
{hover && thumbUrl && createPortal(
|
||||
@ -82,13 +85,19 @@ export function renderPromptWithMentions(
|
||||
assetMentions: Record<string, unknown>[],
|
||||
references: { label: string; previewUrl?: string }[]
|
||||
) {
|
||||
// Build lookup: label → thumbUrl
|
||||
const thumbMap = new Map<string, string>();
|
||||
// Build lookup: label → { thumbUrl, assetType }
|
||||
const thumbMap = new Map<string, { thumbUrl: string; assetType: string }>();
|
||||
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<string, unknown>).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 <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;
|
||||
return part;
|
||||
|
||||
@ -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]++);
|
||||
|
||||
@ -420,6 +420,8 @@ export const assetsApi = {
|
||||
api.get<AssetGroup & { assets: AssetItem[] }>(`/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<AssetItem>(`/assets/groups/${groupId}/assets`, data, { headers: { 'Content-Type': 'multipart/form-data' } }),
|
||||
updateAsset: (id: number, data: { name: string }) =>
|
||||
|
||||
@ -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 };
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user