fix: 素材库引用缩略图烂图 + pollStatus 跨项目素材保护
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

- MentionTag: onError fallback,缩略图加载失败显示视频/图片占位图标
- createMentionSpan/VideoDetailModal: img onError 隐藏烂图
- buildReferenceSnapshots: 素材库引用用 thumb_url 做 previewUrl
- isAssetRef 标记防止视频缩略图被 <video> 渲染、重编辑防重复
- pollStatus: 已 active 的素材跳过远程查询,防止跨项目素材被误删

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-04-13 11:11:12 +08:00
parent db1bbfa1d4
commit 5972f45784
6 changed files with 51 additions and 25 deletions

View File

@ -3446,7 +3446,8 @@ def asset_poll_status_view(request, asset_id):
except Asset.DoesNotExist: except Asset.DoesNotExist:
return Response({'error': '素材不存在'}, status=status.HTTP_404_NOT_FOUND) return Response({'error': '素材不存在'}, status=status.HTTP_404_NOT_FOUND)
if asset.remote_asset_id: # 已经 active 且有 URL 的素材跳过远程查询(避免跨项目素材被误删)
if asset.remote_asset_id and asset.status != 'active':
from utils import assets_client from utils import assets_client
from utils.assets_client import AssetsAPIError from utils.assets_client import AssetsAPIError
try: try:

View File

@ -39,9 +39,12 @@ const DownloadIcon = () => (
// Mention tag with thumbnail + hover preview // Mention tag with thumbnail + hover preview
function MentionTag({ label, thumbUrl, assetType }: { label: string; thumbUrl?: string; assetType?: string }) { function MentionTag({ label, thumbUrl, assetType }: { label: string; thumbUrl?: string; assetType?: string }) {
const [hover, setHover] = useState(false); const [hover, setHover] = useState(false);
const [thumbBroken, setThumbBroken] = 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'; const isAudio = assetType === 'Audio' || assetType === 'audio';
const isVideo = assetType === 'Video' || assetType === 'video';
const showThumb = thumbUrl && !thumbBroken;
return ( return (
<> <>
@ -49,7 +52,7 @@ function MentionTag({ label, thumbUrl, assetType }: { label: string; thumbUrl?:
ref={ref} ref={ref}
className={styles.mentionTag} className={styles.mentionTag}
onMouseEnter={() => { onMouseEnter={() => {
if (!isAudio && thumbUrl && ref.current) { if (!isAudio && showThumb && 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);
@ -59,18 +62,30 @@ function MentionTag({ label, thumbUrl, assetType }: { label: string; thumbUrl?:
> >
{isAudio ? ( {isAudio ? (
<span style={{ marginRight: 3, fontSize: 13, verticalAlign: 'middle' }}></span> <span style={{ marginRight: 3, fontSize: 13, verticalAlign: 'middle' }}></span>
) : thumbUrl ? ( ) : showThumb ? (
<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 }}
onError={() => setThumbBroken(true)}
/> />
) : null} ) : isVideo ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" style={{ verticalAlign: 'middle', marginRight: 3, opacity: 0.6 }}>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" style={{ verticalAlign: 'middle', marginRight: 3, opacity: 0.6 }}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" stroke="none" />
<path d="M21 15l-5-5L5 21" />
</svg>
)}
{label} {label}
</span> </span>
{hover && thumbUrl && createPortal( {hover && showThumb && createPortal(
<div className={styles.mentionPreview} style={{ top: pos.top, left: pos.left }}> <div className={styles.mentionPreview} style={{ top: pos.top, left: pos.left }}>
<img src={tosThumb(thumbUrl, 200)} alt={label} className={styles.mentionPreviewImg} /> <img src={tosThumb(thumbUrl, 200)} alt={label} className={styles.mentionPreviewImg} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
<div className={styles.mentionPreviewLabel}>{label}</div> <div className={styles.mentionPreviewLabel}>{label}</div>
</div>, </div>,
document.body document.body
@ -149,7 +164,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
const [detailPos, setDetailPos] = useState({ top: 0, right: 0 }); const [detailPos, setDetailPos] = useState({ top: 0, right: 0 });
const detailLinkRef = useRef<HTMLSpanElement>(null); const detailLinkRef = useRef<HTMLSpanElement>(null);
const detailLeaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const detailLeaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [refPreview, setRefPreview] = useState<{ url: string; label: string; type: string; top: number; left: number } | null>(null); const [refPreview, setRefPreview] = useState<{ url: string; label: string; type: string; top: number; left: number; isAssetRef?: boolean } | null>(null);
const startDetailLeave = useCallback(() => { const startDetailLeave = useCallback(() => {
if (detailLeaveTimer.current) clearTimeout(detailLeaveTimer.current); if (detailLeaveTimer.current) clearTimeout(detailLeaveTimer.current);
@ -294,11 +309,11 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (ref.type === 'audio') return; if (ref.type === 'audio') return;
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
setRefPreview({ url: ref.previewUrl, label: ref.label, type: ref.type, top: rect.top - 8, left: rect.left + rect.width / 2 }); setRefPreview({ url: ref.previewUrl, label: ref.label, type: ref.type, top: rect.top - 8, left: rect.left + rect.width / 2, isAssetRef: ref.isAssetRef });
}} }}
onMouseLeave={() => setRefPreview(null)} onMouseLeave={() => setRefPreview(null)}
> >
{ref.type === 'video' ? ( {ref.type === 'video' && !ref.isAssetRef ? (
<video src={ref.previewUrl} className={styles.refMedia} muted /> <video src={ref.previewUrl} className={styles.refMedia} muted />
) : ref.type === 'audio' ? ( ) : ref.type === 'audio' ? (
<div className={styles.audioThumb}> <div className={styles.audioThumb}>
@ -309,7 +324,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
</svg> </svg>
</div> </div>
) : ( ) : (
<img src={tosThumb(ref.previewUrl, 112)} alt={ref.label} className={styles.refMedia} /> <img src={tosThumb(ref.previewUrl, 112)} alt={ref.label} className={styles.refMedia} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
)} )}
</div> </div>
))} ))}
@ -421,10 +436,10 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
{/* Reference thumbnail hover preview */} {/* Reference thumbnail hover preview */}
{refPreview && createPortal( {refPreview && createPortal(
<div className={styles.mentionPreview} style={{ top: refPreview.top, left: refPreview.left }}> <div className={styles.mentionPreview} style={{ top: refPreview.top, left: refPreview.left }}>
{refPreview.type === 'video' ? ( {refPreview.type === 'video' && !refPreview.isAssetRef ? (
<video src={refPreview.url} className={styles.mentionPreviewImg} autoPlay loop muted playsInline /> <video src={refPreview.url} className={styles.mentionPreviewImg} autoPlay loop muted playsInline />
) : ( ) : (
<img src={tosThumb(refPreview.url, 300)} alt={refPreview.label} className={styles.mentionPreviewImg} /> <img src={tosThumb(refPreview.url, 300)} alt={refPreview.label} className={styles.mentionPreviewImg} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
)} )}
<div className={styles.mentionPreviewLabel}>{refPreview.label}</div> <div className={styles.mentionPreviewLabel}>{refPreview.label}</div>
</div>, </div>,

View File

@ -98,6 +98,7 @@ export function PromptInput() {
img.setAttribute('width', '16'); img.setAttribute('width', '16');
img.setAttribute('height', '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'; 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';
img.onerror = () => { img.style.display = 'none'; };
span.appendChild(img); span.appendChild(img);
} }
// @ 前缀隐藏textContent 保留用于模式匹配,视觉上不显示) // @ 前缀隐藏textContent 保留用于模式匹配,视觉上不显示)

View File

@ -220,9 +220,9 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
if (task.model) store.setModel(task.model as 'seedance_2.0' | 'seedance_2.0_fast'); if (task.model) store.setModel(task.model as 'seedance_2.0' | 'seedance_2.0_fast');
if (task.aspectRatio) store.setAspectRatio(task.aspectRatio as any); if (task.aspectRatio) store.setAspectRatio(task.aspectRatio as any);
if (task.duration) store.setDuration(task.duration); if (task.duration) store.setDuration(task.duration);
// Load references from task // Load references from task (exclude asset library refs — they restore via @mentions in editorHtml)
if (task.references && task.references.length > 0) { if (task.references && task.references.length > 0) {
const refs = task.references.filter(r => r.previewUrl).map(r => ({ const refs = task.references.filter(r => r.previewUrl && !r.isAssetRef).map(r => ({
id: r.id, id: r.id,
file: null as unknown as File, file: null as unknown as File,
previewUrl: r.previewUrl, previewUrl: r.previewUrl,
@ -485,7 +485,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
{task.references.map((ref) => ( {task.references.map((ref) => (
<div key={ref.id} className={styles.refItem}> <div key={ref.id} className={styles.refItem}>
<div style={{ position: 'relative', width: 56, height: 56 }}> <div style={{ position: 'relative', width: 56, height: 56 }}>
{ref.type === 'video' ? ( {ref.type === 'video' && !ref.isAssetRef ? (
<video src={ref.previewUrl} className={styles.refImg} muted style={{ cursor: 'pointer' }} onClick={() => ref.previewUrl && setRefMediaPreview({ url: ref.previewUrl, type: 'video' })} /> <video src={ref.previewUrl} className={styles.refImg} muted style={{ cursor: 'pointer' }} onClick={() => ref.previewUrl && setRefMediaPreview({ url: ref.previewUrl, type: 'video' })} />
) : ref.type === 'audio' ? ( ) : ref.type === 'audio' ? (
<div className={styles.refAudioPlaceholder} style={{ cursor: 'pointer' }} onClick={() => ref.previewUrl && setRefMediaPreview({ url: ref.previewUrl, type: 'audio' })}> <div className={styles.refAudioPlaceholder} style={{ cursor: 'pointer' }} onClick={() => ref.previewUrl && setRefMediaPreview({ url: ref.previewUrl, type: 'audio' })}>
@ -496,7 +496,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
</svg> </svg>
</div> </div>
) : ref.previewUrl ? ( ) : ref.previewUrl ? (
<img src={tosThumb(ref.previewUrl, 300)} alt={ref.label} className={styles.refImg} style={{ cursor: 'zoom-in' }} onClick={() => setLightboxSrc(ref.previewUrl)} /> <img src={tosThumb(ref.previewUrl, 300)} alt={ref.label} className={styles.refImg} style={{ cursor: 'zoom-in' }} onClick={() => setLightboxSrc(ref.previewUrl)} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
) : ( ) : (
<div className={styles.refAudioPlaceholder} style={{ fontSize: 12, color: 'var(--color-text-disabled)' }}></div> <div className={styles.refAudioPlaceholder} style={{ fontSize: 12, color: 'var(--color-text-disabled)' }}></div>
)} )}

View File

@ -59,7 +59,7 @@ function isAssetUrl(url: string): boolean {
return url.startsWith('asset://') || url.startsWith('Asset://'); return url.startsWith('asset://') || url.startsWith('Asset://');
} }
/** Build ReferenceSnapshot[] from raw reference_urls, excluding asset refs. */ /** Build ReferenceSnapshot[] from raw reference_urls (including asset refs with thumb_url). */
function buildReferenceSnapshots( function buildReferenceSnapshots(
refs: Array<Record<string, string>>, refs: Array<Record<string, string>>,
taskId: string, taskId: string,
@ -67,15 +67,23 @@ function buildReferenceSnapshots(
return refs return refs
.filter((ref) => { .filter((ref) => {
const url = ref.url || ''; const url = ref.url || '';
return !isAssetUrl(url) && url.trim() !== ''; // 素材库引用必须有 thumb_url 才能显示缩略图
if (isAssetUrl(url)) return !!(ref.thumb_url);
return url.trim() !== '';
}) })
.map((ref, i) => ({ .map((ref, i) => {
const url = ref.url || '';
const assetRef = isAssetUrl(url);
return {
id: `ref_${taskId}_${i}`, id: `ref_${taskId}_${i}`,
type: (ref.type || 'image') as 'image' | 'video' | 'audio', type: (ref.type || 'image') as 'image' | 'video' | 'audio',
previewUrl: ref.url || '', // 素材库引用用 thumb_url直接上传用原始 url
previewUrl: assetRef ? ref.thumb_url : url,
label: ref.label || `素材${i + 1}`, label: ref.label || `素材${i + 1}`,
role: ref.role, role: ref.role,
})); isAssetRef: assetRef || undefined,
};
});
} }
/** Extract asset mention metadata from raw reference_urls. */ /** Extract asset mention metadata from raw reference_urls. */

View File

@ -32,6 +32,7 @@ export interface ReferenceSnapshot {
previewUrl: string; previewUrl: string;
label: string; label: string;
role?: string; role?: string;
isAssetRef?: boolean;
} }
export interface GenerationTask { export interface GenerationTask {