video-shuoshan/web/src/components/AssetLibraryModal.tsx
seaislee1209 0a1a3a266c feat: v0.12.4 素材库优化 + UI 修复
①素材组名字自动从火山同步(打开素材库时一次 API 调用)
②空素材组显示「暂无图片」替代烂图(列表页 + @搜索弹窗)
③@搜索支持英文角色名(去掉中文正则限制)
④素材上传页显示图片尺寸要求红字提示
⑤图片尺寸报错改为白话文案
⑥个人中心页面支持滚动

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:18:06 +08:00

443 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, 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)}>
{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.actionBtn} onClick={() => addFileInputRef.current?.click()}>
+
</button>
<button
className={styles.actionBtnOutline}
onClick={() => setEditingName({ id: selectedGroup.id, value: selectedGroup.name })}
>
&#9998;
</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}> JPGPNG 30MB</div>
</>
)}
<div className={styles.dropZoneWarning}> </div>
<div className={styles.dropZoneWarning}> 300~6000 </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>
);
}