Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
- 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>
563 lines
26 KiB
TypeScript
563 lines
26 KiB
TypeScript
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 });
|
||
}}
|
||
>
|
||
✎
|
||
</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 })}
|
||
>
|
||
✎ 改名
|
||
</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>
|
||
);
|
||
}
|