video-shuoshan/web/src/components/AssetLibraryModal.tsx
seaislee1209 da9a1413c3 v0.18.0 素材库多类型支持 + @ 引用改为单素材
对齐火山 API 文档(Asset URI 小写、HEIC/HEIF、DeleteAsset)
素材库支持视频/音频上传(按类型分三区显示、前端校验、拖拽上传)
@ 引用从素材组改为单个素材(搜索返回具体素材、即时数量/时长检查)
ffmpeg 视频封面帧提取 + 音频时长读取(Celery 异步)
生产级安全修复(跨团队校验、异常信息脱敏、下载大小限制)
2026-04-04 17:36:35 +08:00

549 lines
25 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>
</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>
);
}