video-shuoshan/web/src/components/AssetLibraryModal.tsx
seaislee1209 5da67435b2
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
fix: v0.18.1 用户测试 8 项 Bug 修复
- 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>
2026-04-04 21:06:02 +08:00

563 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback } from 'react';
import { useAssetLibraryStore } from '../store/assetLibrary';
import { assetsApi, tosThumb } from '../lib/api';
import { showToast } from './Toast';
import { ImageLightbox } from './ImageLightbox';
import type { AssetGroup, AssetItem } from '../types';
import styles from './AssetLibraryModal.module.css';
/** Validate asset file before upload. Returns error message or null if valid. */
async function validateAssetFile(file: File): Promise<string | null> {
const ct = file.type || '';
if (ct.startsWith('image/')) {
// Format: accept all image/* since backend checks ext
if (file.size > 30 * 1024 * 1024) return '图片文件不能超过 30MB';
// Dimension check
try {
const dims = await new Promise<{ w: number; h: number }>((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => { resolve({ w: img.naturalWidth, h: img.naturalHeight }); URL.revokeObjectURL(url); };
img.onerror = () => { reject(); URL.revokeObjectURL(url); };
img.src = url;
});
if (dims.w <= 300 || dims.h <= 300) return `图片尺寸过小(${dims.w}×${dims.h}),宽高需在 300~6000 像素之间`;
if (dims.w >= 6000 || dims.h >= 6000) return `图片尺寸过大(${dims.w}×${dims.h}),宽高需在 300~6000 像素之间`;
const ratio = dims.w / dims.h;
if (ratio <= 0.4 || ratio >= 2.5) return `图片比例不支持(${dims.w}×${dims.h}),宽高比需在 0.4~2.5 之间`;
} catch {
// Can't read dimensions (e.g. HEIC), skip — backend will validate
}
return null;
}
if (ct.startsWith('video/')) {
if (ct !== 'video/mp4' && ct !== 'video/quicktime') return '仅支持 MP4 和 MOV 格式的视频';
if (file.size > 50 * 1024 * 1024) return '视频文件不能超过 50MB';
// Duration + dimension check
try {
const info = await new Promise<{ dur: number; w: number; h: number }>((resolve, reject) => {
const vid = document.createElement('video');
const url = URL.createObjectURL(file);
const timeout = setTimeout(() => { reject(); URL.revokeObjectURL(url); }, 10000);
vid.addEventListener('loadedmetadata', () => {
clearTimeout(timeout);
resolve({ dur: vid.duration, w: vid.videoWidth, h: vid.videoHeight });
URL.revokeObjectURL(url);
});
vid.addEventListener('error', () => { clearTimeout(timeout); reject(); URL.revokeObjectURL(url); });
vid.src = url;
});
if (info.dur < 2 || info.dur > 15.4) return `视频时长需在 2~15 秒之间(当前 ${info.dur.toFixed(1)} 秒)`;
if (info.w < 300 || info.h < 300) return `视频尺寸过小(${info.w}×${info.h}),宽高需在 300~6000 像素之间`;
if (info.w > 6000 || info.h > 6000) return `视频尺寸过大(${info.w}×${info.h}),宽高需在 300~6000 像素之间`;
const ratio = info.w / info.h;
if (ratio < 0.4 || ratio > 2.5) return `视频比例不支持(${info.w}×${info.h}),宽高比需在 0.4~2.5 之间`;
const pixels = info.w * info.h;
if (pixels < 409600) return `视频像素过低(${info.w}×${info.h}=${pixels.toLocaleString()}),需在 409,600~927,408 之间`;
if (pixels > 927408) return `视频像素过高(${info.w}×${info.h}=${pixels.toLocaleString()}),需在 409,600~927,408 之间`;
} catch {
// Can't read metadata, skip — backend will validate
}
return null;
}
if (ct.startsWith('audio/')) {
if (ct !== 'audio/mpeg' && ct !== 'audio/wav') return '仅支持 MP3 和 WAV 格式的音频';
if (file.size > 15 * 1024 * 1024) return '音频文件不能超过 15MB';
// Duration check
try {
const dur = await new Promise<number>((resolve, reject) => {
const audio = new Audio();
const url = URL.createObjectURL(file);
const timeout = setTimeout(() => { reject(); URL.revokeObjectURL(url); }, 10000);
audio.addEventListener('loadedmetadata', () => {
clearTimeout(timeout);
resolve(audio.duration);
URL.revokeObjectURL(url);
});
audio.addEventListener('error', () => { clearTimeout(timeout); reject(); URL.revokeObjectURL(url); });
audio.src = url;
});
if (dur < 2 || dur > 15.4) return `音频时长需在 2~15 秒之间(当前 ${dur.toFixed(1)} 秒)`;
} catch {
// Can't read metadata, skip
}
return null;
}
return '不支持的文件类型';
}
interface Props {
open: boolean;
onClose: () => void;
}
export function AssetLibraryModal({ open, onClose }: Props) {
const [view, setView] = useState<'list' | 'detail' | 'upload'>('list');
const [selectedGroup, setSelectedGroup] = useState<AssetGroup | null>(null);
const [groupAssets, setGroupAssets] = useState<AssetItem[]>([]);
const [newName, setNewName] = useState('');
const [uploading, setUploading] = useState(false);
const [editingName, setEditingName] = useState<{ id: number; value: string } | null>(null);
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
const groups = useAssetLibraryStore((s) => s.groups);
const loading = useAssetLibraryStore((s) => s.loading);
const total = useAssetLibraryStore((s) => s.total);
const page = useAssetLibraryStore((s) => s.page);
const loadGroups = useAssetLibraryStore((s) => s.loadGroups);
const createGroup = useAssetLibraryStore((s) => s.createGroup);
const totalPages = Math.ceil(total / 20);
useEffect(() => {
if (open) {
loadGroups(1);
setView('list');
setSelectedGroup(null);
}
}, [open, loadGroups]);
const handleGroupClick = useCallback(async (group: AssetGroup) => {
setSelectedGroup(group);
try {
const { data } = await assetsApi.getGroupDetail(group.id);
const assets: AssetItem[] = data.assets || [];
setGroupAssets(assets);
// 对所有素材检查一次云端状态(处理中的更新状态,被删的清理掉)
let needRefresh = false;
const checks = assets.map((asset) =>
assetsApi.pollStatus(asset.id).then(({ data: statusData }) => {
if (statusData.status !== asset.status || statusData.status as string === 'deleted') {
needRefresh = true;
}
}).catch(() => {})
);
Promise.all(checks).then(() => {
if (needRefresh) {
assetsApi.getGroupDetail(group.id).then(({ data: refreshed }) => {
setGroupAssets(refreshed.assets || []);
}).catch(() => {});
}
});
} catch {
setGroupAssets([]);
}
setView('detail');
}, []);
const handleBackToList = useCallback(() => {
setView('list');
setSelectedGroup(null);
setGroupAssets([]);
setEditingName(null);
loadGroups(page);
}, [loadGroups, page]);
const handleRenameGroup = useCallback(async (id: number, name: string) => {
try {
await assetsApi.updateGroup(id, { name });
showToast('重命名成功');
setEditingName(null);
loadGroups(page);
if (selectedGroup && selectedGroup.id === id) {
setSelectedGroup({ ...selectedGroup, name });
}
} catch {
showToast('重命名失败');
}
}, [loadGroups, page, selectedGroup]);
const handleUploadSubmit = useCallback(async () => {
const trimmed = newName.trim();
if (!trimmed) return;
if (trimmed.length > 64) { showToast('角色名称不能超过64个字符'); return; }
if (trimmed.includes('&&')) { showToast('角色名称不能包含 &&'); return; }
setUploading(true);
const result = await createGroup(trimmed, null);
setUploading(false);
if (result) {
setNewName('');
// 创建成功后直接进入详情页
const group: AssetGroup = { id: result.id, name: trimmed, thumbnail_url: '', asset_count: 0, remote_group_id: result.remote_group_id || '', description: '', created_at: new Date().toISOString() };
setSelectedGroup(group);
setGroupAssets([]);
setView('detail');
loadGroups(page);
}
}, [newName, createGroup, loadGroups, page]);
const refreshGroupDetail = useCallback(async () => {
if (!selectedGroup) return;
try {
const { data } = await assetsApi.getGroupDetail(selectedGroup.id);
setGroupAssets(data.assets || []);
} catch { /* ignore */ }
}, [selectedGroup]);
const handleAddAsset = useCallback(async (file: File) => {
if (!selectedGroup) return;
const error = await validateAssetFile(file);
if (error) { showToast(error); return; }
const formData = new FormData();
formData.append('file', file);
try {
const { data } = await assetsApi.addAsset(selectedGroup.id, formData);
setGroupAssets((prev) => [...prev, data]);
// 轮询状态,完成后刷新详情
const pollId = data.id;
const pollInterval = setInterval(async () => {
try {
const { data: statusData } = await assetsApi.pollStatus(pollId);
if (statusData.status !== 'processing') {
clearInterval(pollInterval);
if (statusData.status === 'active') showToast('素材已就绪');
else if (statusData.status === 'deleted') showToast('素材在云端已被删除');
else showToast('素材处理失败');
refreshGroupDetail();
}
} catch {
clearInterval(pollInterval);
}
}, 3000);
const typeLabel = file.type.startsWith('video/') ? '视频' : file.type.startsWith('audio/') ? '音频' : '图片';
showToast(`${typeLabel}已上传,处理中...`);
} catch {
showToast('上传失败,请重试');
}
}, [selectedGroup, refreshGroupDetail]);
if (!open) return null;
return (
<div className={styles.overlay} onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className={styles.modal}>
{/* Header */}
<div className={styles.header}>
<div className={styles.headerLeft}>
{view !== 'list' && (
<button className={styles.backBtn} onClick={handleBackToList}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
)}
<span className={styles.title}>
{view === 'list' && '人物素材库'}
{view === 'detail' && (selectedGroup?.name || '角色详情')}
{view === 'upload' && '上传新角色'}
</span>
</div>
<button className={styles.closeBtn} onClick={onClose}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* Body */}
<div className={styles.body}>
{/* List View */}
{view === 'list' && (
<>
<div className={styles.actions}>
<button className={styles.actionBtn} onClick={() => setView('upload')}>
+
</button>
</div>
{loading ? (
<div className={styles.empty}>...</div>
) : groups.length === 0 ? (
<div className={styles.empty}></div>
) : (
<div className={styles.grid}>
{groups.map((group) => (
<div key={group.id} className={styles.card} onClick={() => handleGroupClick(group)}>
{group.asset_count === 0 ? (
<div className={styles.cardThumb} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--color-text-disabled)', fontSize: 12 }}></div>
) : (
<img src={tosThumb(group.thumbnail_url, 300)} alt={group.name} className={styles.cardThumb} />
)}
<div className={styles.cardInfo}>
{editingName && editingName.id === group.id ? (
<div className={styles.inlineEditWrap} onClick={(e) => e.stopPropagation()}>
<input
className={styles.inlineInput}
value={editingName.value}
onChange={(e) => setEditingName({ ...editingName, value: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameGroup(group.id, editingName.value);
if (e.key === 'Escape') setEditingName(null);
}}
autoFocus
/>
<button
className={styles.editBtn}
onClick={() => handleRenameGroup(group.id, editingName.value)}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
>
</button>
<button
className={styles.editBtn}
onClick={() => setEditingName(null)}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
>
</button>
</div>
) : (
<>
<span className={styles.cardName}>{group.name}</span>
<button
className={styles.editBtn}
onClick={(e) => {
e.stopPropagation();
setEditingName({ id: group.id, value: group.name });
}}
>
&#9998;
</button>
</>
)}
</div>
</div>
))}
</div>
)}
{totalPages > 1 && (
<div className={styles.pagination}>
<button
className={styles.pageBtn}
disabled={page <= 1}
onClick={() => loadGroups(page - 1)}
>
</button>
<span className={styles.pageInfo}>{page} / {totalPages}</span>
<button
className={styles.pageBtn}
disabled={page >= totalPages}
onClick={() => loadGroups(page + 1)}
>
</button>
</div>
)}
</>
)}
{/* Detail View */}
{view === 'detail' && selectedGroup && (
<>
<div className={styles.actions}>
<button
className={styles.actionBtnOutline}
onClick={() => setEditingName({ id: selectedGroup.id, value: selectedGroup.name })}
>
&#9998;
</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 && (
<div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}>
<input
className={styles.textInput}
style={{ flex: 1 }}
value={editingName.value}
onChange={(e) => setEditingName({ ...editingName, value: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameGroup(selectedGroup.id, editingName.value);
if (e.key === 'Escape') setEditingName(null);
}}
autoFocus
/>
<button
className={styles.actionBtn}
onClick={() => handleRenameGroup(selectedGroup.id, editingName.value)}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
>
</button>
<button
className={styles.actionBtnOutline}
onClick={() => setEditingName(null)}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
>
</button>
</div>
)}
{/* ── 按类型分区显示 ── */}
{(['Image', 'Video', 'Audio'] as const).map((assetType) => {
const typeAssets = groupAssets.filter((a) => (a.asset_type || 'Image') === assetType);
const typeLabel = assetType === 'Image' ? '肖像(图片)' : assetType === 'Video' ? '视频' : '音频';
const acceptMap = { Image: 'image/*', Video: 'video/mp4,video/quicktime', Audio: 'audio/mpeg,audio/wav' };
const hintMap = {
Image: '支持 JPG、PNG、WEBP、HEIC单张不超过 30MB',
Video: '支持 MP4、MOV单个不超过 50MB',
Audio: '支持 MP3、WAV单个不超过 15MB',
};
const warningMap = {
Image: '⚠️ 宽高 300~6000 像素,宽高比 0.4~2.5',
Video: '⚠️ 时长 2~15 秒,宽高 300~6000 像素,帧率 24~60 FPS',
Audio: '⚠️ 时长 2~15 秒',
};
return (
<div key={assetType} style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>{typeLabel}</span>
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-disabled)', marginBottom: 2 }}>{hintMap[assetType]}</div>
<div style={{ fontSize: 11, color: '#e8952e', marginBottom: 8 }}>{warningMap[assetType]}</div>
<div className={styles.assetGrid}>
{typeAssets.map((asset) => (
<div key={asset.id} className={styles.assetCard}>
{assetType === 'Video' ? (
<img src={tosThumb(asset.thumbnail_url || asset.url, 300)} alt={asset.name} className={styles.assetThumb} />
) : assetType === 'Audio' ? (
<div className={styles.assetThumb} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 32, background: '#1a1a2e' }}></div>
) : (
<img
src={tosThumb(asset.url, 300)}
alt={asset.name}
className={styles.assetThumb}
style={{ cursor: 'zoom-in' }}
onClick={() => setLightboxSrc(asset.url)}
/>
)}
<button
className={styles.assetDeleteBtn}
onClick={(e) => {
e.stopPropagation();
if (confirm('确认删除此素材?删除后无法恢复。')) {
assetsApi.deleteAsset(asset.id).then(() => {
showToast('素材已删除');
if (selectedGroup) {
assetsApi.getGroupDetail(selectedGroup.id).then(({ data }) => {
setGroupAssets(data.assets || []);
});
}
loadGroups(page);
}).catch(() => showToast('删除失败,请重试'));
}
}}
title="删除素材"
>×</button>
<div className={styles.assetInfo}>
<div className={styles.assetName}>{asset.name}</div>
<span
className={`${styles.statusBadge} ${
asset.status === 'active' ? styles.statusActive
: asset.status === 'processing' ? styles.statusProcessing
: styles.statusFailed
}`}
title={asset.status === 'failed' ? (asset.error_message || '素材处理失败,请删除后重新上传') : undefined}
>
{asset.status === 'active' && '可用'}
{asset.status === 'processing' && '处理中'}
{asset.status === 'failed' && '失败'}
</span>
</div>
</div>
))}
{/* 拖拽上传卡片 — 和素材卡片同大小,始终在最后 */}
<label
className={styles.addAssetCard}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (!file) return;
// 检查文件类型是否匹配当前分区
const ft = file.type || '';
const matchesSection =
(assetType === 'Image' && ft.startsWith('image/')) ||
(assetType === 'Video' && ft.startsWith('video/')) ||
(assetType === 'Audio' && ft.startsWith('audio/'));
if (!matchesSection) {
const expected = assetType === 'Image' ? '图片' : assetType === 'Video' ? '视频' : '音频';
showToast(`请将${expected}文件拖到此区域`);
return;
}
handleAddAsset(file);
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
<span></span>
<input
type="file"
accept={acceptMap[assetType]}
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleAddAsset(file);
e.target.value = '';
}}
/>
</label>
</div>
</div>
);
})}
</>
)}
{/* Upload View — only name, no file */}
{view === 'upload' && (
<div className={styles.uploadForm}>
<div>
<div className={styles.inputLabel}></div>
<input
className={styles.textInput}
placeholder="请输入角色名称,如:林峰"
maxLength={64}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleUploadSubmit(); }}
autoFocus
/>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-disabled)', marginTop: 4 }}>
</div>
<button
className={styles.submitBtn}
disabled={!newName.trim() || uploading}
onClick={handleUploadSubmit}
>
{uploading ? '创建中...' : '创建角色'}
</button>
</div>
)}
</div>
</div>
<ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />
</div>
);
}