图片缩略图优化: - 新增 tosThumb() 工具函数,TOS 图片按显示尺寸 2x 加载缩略图 - 所有小图(任务卡片、mention 标签、hover 预览、素材库、输入栏参考图)全部走缩略图 - 原图仅在 ImageLightbox 大图预览和提交生成时使用 - tosThumb 只匹配 airdrama-media 桶,不影响火山内部桶 URL 素材库修复: - 旧数据图片从火山桶同步到我们 TOS 桶(一次性脚本) - 素材详情页图片支持点击看大图(ImageLightbox) - 弹窗高度固定 85vh,三个视图高度一致 - 列表页点击图片进素材组,不触发预览 - 视频敏感内容错误码映射补充 UI 细节: - 任务卡片参考图 hover 预览(上方弹出) - 详细信息弹窗延迟关闭(鼠标可移到弹窗上) - 删除@后 mention 弹窗自动关闭 - 导航箭头禁用时不触发关闭弹窗 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
438 lines
18 KiB
TypeScript
438 lines
18 KiB
TypeScript
import { useState, useEffect, useRef, 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';
|
||
|
||
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 [uploadFile, setUploadFile] = useState<File | null>(null);
|
||
const [uploadPreview, setUploadPreview] = useState<string | null>(null);
|
||
const [dragOver, setDragOver] = useState(false);
|
||
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const addFileInputRef = useRef<HTMLInputElement>(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 pollAssetStatus = useAssetLibraryStore((s) => s.pollAssetStatus);
|
||
|
||
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 || !uploadFile) return;
|
||
if (trimmed.length > 64) { showToast('角色名称不能超过64个字符'); return; }
|
||
if (trimmed.includes('&&')) { showToast('角色名称不能包含 &&'); return; }
|
||
setUploading(true);
|
||
const result = await createGroup(newName.trim(), uploadFile);
|
||
setUploading(false);
|
||
if (result) {
|
||
pollAssetStatus(result.id);
|
||
setNewName('');
|
||
setUploadFile(null);
|
||
if (uploadPreview) URL.revokeObjectURL(uploadPreview);
|
||
setUploadPreview(null);
|
||
handleBackToList();
|
||
}
|
||
}, [newName, uploadFile, createGroup, pollAssetStatus, uploadPreview, handleBackToList]);
|
||
|
||
const handleFileSelect = useCallback((file: File) => {
|
||
if (uploadPreview) URL.revokeObjectURL(uploadPreview);
|
||
setUploadFile(file);
|
||
setUploadPreview(URL.createObjectURL(file));
|
||
}, [uploadPreview]);
|
||
|
||
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 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);
|
||
showToast('图片已上传,处理中...');
|
||
} catch {
|
||
showToast('上传失败,请重试');
|
||
}
|
||
}, [selectedGroup, refreshGroupDetail]);
|
||
|
||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
setDragOver(false);
|
||
const file = e.dataTransfer.files[0];
|
||
if (file && file.type.startsWith('image/')) {
|
||
handleFileSelect(file);
|
||
}
|
||
}, [handleFileSelect]);
|
||
|
||
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)}>
|
||
<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 });
|
||
}}
|
||
>
|
||
✎
|
||
</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.actionBtn} onClick={() => addFileInputRef.current?.click()}>
|
||
+ 追加图片
|
||
</button>
|
||
<button
|
||
className={styles.actionBtnOutline}
|
||
onClick={() => setEditingName({ id: selectedGroup.id, value: selectedGroup.name })}
|
||
>
|
||
✎ 改名
|
||
</button>
|
||
<input
|
||
ref={addFileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
style={{ display: 'none' }}
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) handleAddAsset(file);
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
</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>
|
||
)}
|
||
|
||
{groupAssets.length === 0 ? (
|
||
<div className={styles.empty}>暂无素材图片</div>
|
||
) : (
|
||
<div className={styles.assetGrid}>
|
||
{groupAssets.map((asset) => (
|
||
<div key={asset.id} className={styles.assetCard}>
|
||
<img
|
||
src={tosThumb(asset.url, 300)}
|
||
alt={asset.name}
|
||
className={styles.assetThumb}
|
||
style={{ cursor: 'zoom-in' }}
|
||
onClick={() => setLightboxSrc(asset.url)}
|
||
/>
|
||
<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
|
||
}`}>
|
||
{asset.status === 'active' && '可用'}
|
||
{asset.status === 'processing' && '处理中'}
|
||
{asset.status === 'failed' && '失败'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Upload View */}
|
||
{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)}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<div className={styles.inputLabel}>角色图片</div>
|
||
<div
|
||
className={`${styles.dropZone} ${dragOver ? styles.dropZoneActive : ''}`}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||
onDragLeave={() => setDragOver(false)}
|
||
onDrop={handleDrop}
|
||
>
|
||
{uploadPreview ? (
|
||
<>
|
||
<img src={uploadPreview} alt="预览" className={styles.dropZonePreview} />
|
||
<div className={styles.dropZoneHint}>点击重新选择</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className={styles.dropZoneText}>上传角色图片</div>
|
||
<div className={styles.dropZoneHint}>将角色的正面图或三视图拖拽到这里,或点击选择文件</div>
|
||
<div className={styles.dropZoneHint}>支持 JPG、PNG 格式,单张不超过 30MB</div>
|
||
</>
|
||
)}
|
||
<div className={styles.dropZoneWarning}>⚠️ 素材上传后无法删除,请确认后再上传</div>
|
||
</div>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
style={{ display: 'none' }}
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) handleFileSelect(file);
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
className={styles.submitBtn}
|
||
disabled={!newName.trim() || !uploadFile || uploading}
|
||
onClick={handleUploadSubmit}
|
||
>
|
||
{uploading ? '上传中...' : '确认上传'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />
|
||
</div>
|
||
);
|
||
}
|