600 lines
24 KiB
TypeScript
600 lines
24 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
App,
|
|
Button,
|
|
Card,
|
|
Col,
|
|
Empty,
|
|
Form,
|
|
Input,
|
|
Modal,
|
|
Pagination,
|
|
Popconfirm,
|
|
Row,
|
|
Select,
|
|
Space,
|
|
Spin,
|
|
Tag,
|
|
Tooltip,
|
|
Typography,
|
|
Upload,
|
|
} from 'antd';
|
|
import type { UploadFile } from 'antd/es/upload/interface';
|
|
import {
|
|
ArrowLeftOutlined,
|
|
CloudSyncOutlined,
|
|
DeleteOutlined,
|
|
EyeOutlined,
|
|
PictureOutlined,
|
|
PlusOutlined,
|
|
ReloadOutlined,
|
|
UploadOutlined,
|
|
VideoCameraOutlined,
|
|
} from '@ant-design/icons';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
createPrivatePortraitVirtualAsset,
|
|
createPrivatePortraitVirtualProject,
|
|
deletePrivatePortraitVirtualAsset,
|
|
deletePrivatePortraitVirtualProject,
|
|
getPrivatePortraitVirtualAssets,
|
|
getPrivatePortraitVirtualConfig,
|
|
getPrivatePortraitVirtualProjects,
|
|
syncPrivatePortraitVirtualAsset,
|
|
uploadImage,
|
|
uploadVideo,
|
|
} from '../api';
|
|
import type { PrivatePortraitAsset, PrivatePortraitConfig, PrivatePortraitProject } from '../types';
|
|
|
|
const { Text, Title, Paragraph } = Typography;
|
|
|
|
type AssetTypeFilter = 'Image' | 'Video' | undefined;
|
|
|
|
const statusConfig: Record<string, { label: string; color: string }> = {
|
|
creating: { label: '本地创建中', color: 'processing' },
|
|
Processing: { label: '火山处理中', color: 'processing' },
|
|
Active: { label: '可用于生成', color: 'success' },
|
|
Failed: { label: '入库失败', color: 'error' },
|
|
local_deleted: { label: '本地已删', color: 'default' },
|
|
remote_deleted: { label: '远端已删', color: 'default' },
|
|
delete_failed: { label: '远端删除失败', color: 'error' },
|
|
};
|
|
|
|
const assetTypeConfig: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
|
|
Image: { label: '图片', color: 'green', icon: <PictureOutlined /> },
|
|
Video: { label: '视频', color: 'blue', icon: <VideoCameraOutlined /> },
|
|
};
|
|
|
|
const formatDateTime = (dateStr?: string | null) => {
|
|
if (!dateStr) return '-';
|
|
const date = new Date(dateStr);
|
|
if (Number.isNaN(date.getTime())) return '-';
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
const hours = String(date.getHours()).padStart(2, '0');
|
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
};
|
|
|
|
const formatSize = (size?: number | null) => {
|
|
const value = Number(size || 0);
|
|
if (!value) return '-';
|
|
if (value >= 1024 * 1024 * 1024) return `${(value / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(2)} MB`;
|
|
if (value >= 1024) return `${(value / 1024).toFixed(2)} KB`;
|
|
return `${value} B`;
|
|
};
|
|
|
|
const buildPreviewUrl = (url?: string | null) => {
|
|
if (!url) return '';
|
|
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:') || url.startsWith('blob:')) return url;
|
|
const base = (import.meta.env.VITE_API_BASE || 'http://localhost:8000').replace(/\/$/, '');
|
|
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
|
|
};
|
|
|
|
const getAssetPreviewUrl = (asset: PrivatePortraitAsset) => {
|
|
return buildPreviewUrl(asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.remoteUrl || asset.sourceUrl);
|
|
};
|
|
|
|
const guessAssetType = (file?: File | null): 'Image' | 'Video' => {
|
|
if (!file) return 'Image';
|
|
if (file.type.startsWith('video/')) return 'Video';
|
|
const name = file.name.toLowerCase();
|
|
if (/\.(mp4|mov|webm|m4v|avi|mkv)$/.test(name)) return 'Video';
|
|
return 'Image';
|
|
};
|
|
|
|
const getVideoDuration = (file: File): Promise<number | null> => {
|
|
return new Promise((resolve) => {
|
|
if (!file.type.startsWith('video/')) {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
const url = URL.createObjectURL(file);
|
|
const video = document.createElement('video');
|
|
video.preload = 'metadata';
|
|
video.onloadedmetadata = () => {
|
|
const duration = Number.isFinite(video.duration) ? video.duration : null;
|
|
URL.revokeObjectURL(url);
|
|
resolve(duration);
|
|
};
|
|
video.onerror = () => {
|
|
URL.revokeObjectURL(url);
|
|
resolve(null);
|
|
};
|
|
video.src = url;
|
|
});
|
|
};
|
|
|
|
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
|
|
const value = status || '-';
|
|
const config = statusConfig[value];
|
|
return <Tag color={config?.color || 'default'}>{config?.label || value}</Tag>;
|
|
};
|
|
|
|
const TypeTag: React.FC<{ type?: string | null }> = ({ type }) => {
|
|
const value = type || '-';
|
|
const config = assetTypeConfig[value];
|
|
return <Tag color={config?.color || 'default'} icon={config?.icon}>{config?.label || value}</Tag>;
|
|
};
|
|
|
|
const PrivatePortraitVirtualMaterialPage: React.FC = () => {
|
|
const { message } = App.useApp();
|
|
const navigate = useNavigate();
|
|
const [config, setConfig] = useState<PrivatePortraitConfig | null>(null);
|
|
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
|
const [selectedProjectId, setSelectedProjectId] = useState<string>();
|
|
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
|
|
const [projectLoading, setProjectLoading] = useState(false);
|
|
const [assetLoading, setAssetLoading] = useState(false);
|
|
const [assetPage, setAssetPage] = useState(1);
|
|
const [assetPageSize, setAssetPageSize] = useState(20);
|
|
const [assetTotal, setAssetTotal] = useState(0);
|
|
const [keyword, setKeyword] = useState('');
|
|
const [assetStatus, setAssetStatus] = useState<string>();
|
|
const [assetType, setAssetType] = useState<AssetTypeFilter>();
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [creatingProject, setCreatingProject] = useState(false);
|
|
const [uploadOpen, setUploadOpen] = useState(false);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
|
const [assetName, setAssetName] = useState('');
|
|
const [previewOpen, setPreviewOpen] = useState(false);
|
|
const [previewUrl, setPreviewUrl] = useState('');
|
|
const [previewType, setPreviewType] = useState<'Image' | 'Video'>('Image');
|
|
const [createForm] = Form.useForm<{ name: string; description?: string }>();
|
|
|
|
const selectedProject = useMemo(
|
|
() => projects.find((item) => item.id === selectedProjectId) || null,
|
|
[projects, selectedProjectId],
|
|
);
|
|
|
|
const quotaText = useMemo(() => {
|
|
if (!config) return '额度加载中';
|
|
return `已用 ${config.usedAssetCount || 0} / ${config.assetLimit || 0} 个素材,剩余 ${config.remainingAssetCount || 0}`;
|
|
}, [config]);
|
|
|
|
const loadConfig = async () => {
|
|
try {
|
|
const next = await getPrivatePortraitVirtualConfig();
|
|
setConfig(next);
|
|
} catch (err: any) {
|
|
message.error(err?.message || '加载私域素材额度失败');
|
|
}
|
|
};
|
|
|
|
const loadProjects = async () => {
|
|
setProjectLoading(true);
|
|
try {
|
|
const res = await getPrivatePortraitVirtualProjects({ page: 1, pageSize: 100, status: 'active' });
|
|
const next = res.items || [];
|
|
setProjects(next);
|
|
setSelectedProjectId((prev) => prev && next.some((item) => item.id === prev) ? prev : next[0]?.id);
|
|
} catch (err: any) {
|
|
message.error(err?.message || '加载虚拟人像项目组失败');
|
|
} finally {
|
|
setProjectLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadAssets = async (page = assetPage, pageSize = assetPageSize) => {
|
|
if (!selectedProjectId) {
|
|
setAssets([]);
|
|
setAssetTotal(0);
|
|
return;
|
|
}
|
|
setAssetLoading(true);
|
|
try {
|
|
const res = await getPrivatePortraitVirtualAssets(selectedProjectId, {
|
|
page,
|
|
pageSize,
|
|
keyword: keyword.trim() || undefined,
|
|
status: assetStatus,
|
|
assetType,
|
|
});
|
|
setAssets(res.items || []);
|
|
setAssetTotal(res.total || 0);
|
|
setAssetPage(page);
|
|
setAssetPageSize(pageSize);
|
|
} catch (err: any) {
|
|
message.error(err?.message || '加载虚拟人像素材失败');
|
|
} finally {
|
|
setAssetLoading(false);
|
|
}
|
|
};
|
|
|
|
const reloadAll = async () => {
|
|
await Promise.all([loadConfig(), loadProjects()]);
|
|
};
|
|
|
|
useEffect(() => {
|
|
reloadAll();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (selectedProjectId) loadAssets(1, assetPageSize);
|
|
}, [selectedProjectId]);
|
|
|
|
const handleCreateProject = async () => {
|
|
const values = await createForm.validateFields();
|
|
setCreatingProject(true);
|
|
try {
|
|
const project = await createPrivatePortraitVirtualProject(values);
|
|
message.success('虚拟人像项目组已创建');
|
|
setCreateOpen(false);
|
|
createForm.resetFields();
|
|
await loadProjects();
|
|
setSelectedProjectId(project.id);
|
|
} catch (err: any) {
|
|
message.error(err?.message || '创建项目组失败');
|
|
} finally {
|
|
setCreatingProject(false);
|
|
}
|
|
};
|
|
|
|
const handleUpload = async () => {
|
|
if (!selectedProjectId) {
|
|
message.warning('请先创建或选择项目组');
|
|
return;
|
|
}
|
|
const file = fileList[0]?.originFileObj as File | undefined;
|
|
if (!file) {
|
|
message.warning('请先选择图片或视频素材');
|
|
return;
|
|
}
|
|
const currentType = guessAssetType(file);
|
|
setUploading(true);
|
|
try {
|
|
const uploaded = currentType === 'Video' ? await uploadVideo(file) : await uploadImage(file);
|
|
const duration = currentType === 'Video' ? await getVideoDuration(file) : null;
|
|
await createPrivatePortraitVirtualAsset(selectedProjectId, {
|
|
url: uploaded.url,
|
|
assetType: currentType,
|
|
name: assetName.trim() || file.name,
|
|
videoDuration: duration,
|
|
fileSize: file.size,
|
|
mimeType: file.type || null,
|
|
});
|
|
message.success(currentType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中');
|
|
setUploadOpen(false);
|
|
setFileList([]);
|
|
setAssetName('');
|
|
await Promise.all([loadConfig(), loadProjects(), loadAssets(1, assetPageSize)]);
|
|
} catch (err: any) {
|
|
message.error(err?.message || '上传素材失败');
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
};
|
|
|
|
const handleSyncAsset = async (assetId: string) => {
|
|
try {
|
|
await syncPrivatePortraitVirtualAsset(assetId);
|
|
message.success('素材状态已刷新');
|
|
await Promise.all([loadConfig(), loadProjects(), loadAssets(assetPage, assetPageSize)]);
|
|
} catch (err: any) {
|
|
message.error(err?.message || '刷新素材状态失败');
|
|
}
|
|
};
|
|
|
|
const handleDeleteAsset = async (assetId: string) => {
|
|
try {
|
|
await deletePrivatePortraitVirtualAsset(assetId);
|
|
message.success('素材已删除,远端删除将异步执行');
|
|
await Promise.all([loadConfig(), loadProjects(), loadAssets(assetPage, assetPageSize)]);
|
|
} catch (err: any) {
|
|
message.error(err?.message || '删除素材失败');
|
|
}
|
|
};
|
|
|
|
const handleDeleteProject = async () => {
|
|
if (!selectedProjectId) return;
|
|
try {
|
|
await deletePrivatePortraitVirtualProject(selectedProjectId);
|
|
message.success('项目组已删除,远端资产组将异步删除');
|
|
setSelectedProjectId(undefined);
|
|
await reloadAll();
|
|
} catch (err: any) {
|
|
message.error(err?.message || '删除项目组失败');
|
|
}
|
|
};
|
|
|
|
const openPreview = (asset: PrivatePortraitAsset) => {
|
|
const url = getAssetPreviewUrl(asset);
|
|
if (!url) {
|
|
message.warning('暂无可预览地址');
|
|
return;
|
|
}
|
|
setPreviewUrl(url);
|
|
setPreviewType(asset.assetType === 'Video' ? 'Video' : 'Image');
|
|
setPreviewOpen(true);
|
|
};
|
|
|
|
const renderAssetCard = (asset: PrivatePortraitAsset) => {
|
|
const preview = getAssetPreviewUrl(asset);
|
|
const isVideo = asset.assetType === 'Video';
|
|
return (
|
|
<Card
|
|
key={asset.id}
|
|
hoverable
|
|
bodyStyle={{ padding: 12 }}
|
|
style={{ borderRadius: 16, overflow: 'hidden', borderColor: '#eef2f7' }}
|
|
cover={(
|
|
<div style={{ height: 170, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
|
|
{preview && !isVideo ? (
|
|
<img src={preview} alt={asset.name || '虚拟人像素材'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
) : preview && isVideo && asset.videoCoverUrl ? (
|
|
<img src={preview} alt={asset.name || '视频封面'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
) : isVideo ? (
|
|
<VideoCameraOutlined style={{ fontSize: 42, color: '#64748b' }} />
|
|
) : (
|
|
<PictureOutlined style={{ fontSize: 42, color: '#64748b' }} />
|
|
)}
|
|
{isVideo && <Tag color="blue" style={{ position: 'absolute', left: 10, top: 10 }}>视频</Tag>}
|
|
<Button size="small" shape="circle" icon={<EyeOutlined />} style={{ position: 'absolute', right: 10, top: 10 }} onClick={() => openPreview(asset)} />
|
|
</div>
|
|
)}
|
|
>
|
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
|
<Tooltip title={asset.name || asset.remoteAssetId || asset.id}>
|
|
<Text strong ellipsis style={{ display: 'block' }}>{asset.name || asset.remoteAssetId || '未命名素材'}</Text>
|
|
</Tooltip>
|
|
<Space wrap size={4}>
|
|
<TypeTag type={asset.assetType} />
|
|
<StatusTag status={asset.status} />
|
|
</Space>
|
|
<div style={{ color: '#64748b', fontSize: 12, lineHeight: 1.7 }}>
|
|
<div>大小:{formatSize(asset.fileSize)}</div>
|
|
<div>轮询:{asset.pollCount || 0} 次</div>
|
|
<div>创建:{formatDateTime(asset.createdAt)}</div>
|
|
</div>
|
|
{asset.errorMessage && <div style={{ color: '#ef4444', fontSize: 12 }}>{asset.errorMessage}</div>}
|
|
<Space size={6} wrap>
|
|
<Button size="small" icon={<CloudSyncOutlined />} onClick={() => handleSyncAsset(asset.id)}>同步</Button>
|
|
<Popconfirm title="确认删除这个虚拟人像素材吗?" onConfirm={() => handleDeleteAsset(asset.id)}>
|
|
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
|
</Popconfirm>
|
|
</Space>
|
|
</Space>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div style={{ minHeight: '94vh' }}>
|
|
<Space style={{ marginBottom: 16 }}>
|
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/materials')}>返回素材云</Button>
|
|
<Title level={4} style={{ margin: 0 }}>私域虚拟人像素材库</Title>
|
|
</Space>
|
|
|
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
|
<Col xs={24} md={8}>
|
|
<Card style={{ borderRadius: 16, background: 'linear-gradient(135deg,#f5f3ff,#fff)' }}>
|
|
<Text type="secondary">素材总额度</Text>
|
|
<div style={{ fontSize: 24, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}</div>
|
|
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>真人/虚拟共用,图片/视频共用;音频暂不开放。</Paragraph>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={24} md={8}>
|
|
<Card style={{ borderRadius: 16 }}>
|
|
<Text type="secondary">项目组</Text>
|
|
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{projects.length}</div>
|
|
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>虚拟人像项目会同步创建火山 AIGC Asset Group。</Paragraph>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={24} md={8}>
|
|
<Card style={{ borderRadius: 16 }}>
|
|
<Text type="secondary">当前项目素材</Text>
|
|
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{selectedProject?.assetCount || 0}</div>
|
|
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>仅 Active 状态素材可在 AI 创作中引用。</Paragraph>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Row gutter={[16, 16]}>
|
|
<Col xs={24} lg={7}>
|
|
<Card
|
|
title="虚拟人像项目组"
|
|
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>创建项目组</Button>}
|
|
style={{ borderRadius: 16, minHeight: 520 }}
|
|
>
|
|
<Spin spinning={projectLoading}>
|
|
{projects.length === 0 ? (
|
|
<Empty description="暂无虚拟人像项目组" />
|
|
) : (
|
|
<Space direction="vertical" style={{ width: '100%' }} size={10}>
|
|
{projects.map((project) => {
|
|
const active = selectedProjectId === project.id;
|
|
return (
|
|
<div
|
|
key={project.id}
|
|
onClick={() => setSelectedProjectId(project.id)}
|
|
style={{
|
|
padding: 14,
|
|
borderRadius: 14,
|
|
cursor: 'pointer',
|
|
border: active ? '1px solid #8b5cf6' : '1px solid #e2e8f0',
|
|
background: active ? '#f5f3ff' : '#fff',
|
|
}}
|
|
>
|
|
<Space style={{ width: '100%', justifyContent: 'space-between' }} align="start">
|
|
<div style={{ minWidth: 0 }}>
|
|
<Text strong ellipsis style={{ display: 'block' }}>{project.name}</Text>
|
|
{project.description && <Text type="secondary" ellipsis style={{ display: 'block', fontSize: 12 }}>{project.description}</Text>}
|
|
</div>
|
|
<StatusTag status={project.status} />
|
|
</Space>
|
|
<Space wrap size={4} style={{ marginTop: 10 }}>
|
|
<Tag>总 {project.assetCount || 0}</Tag>
|
|
<Tag color="green">图 {project.imageAssetCount || 0}</Tag>
|
|
<Tag color="blue">视频 {project.videoAssetCount || 0}</Tag>
|
|
<Tag color="success">Active {project.activeAssetCount || 0}</Tag>
|
|
</Space>
|
|
</div>
|
|
);
|
|
})}
|
|
</Space>
|
|
)}
|
|
</Spin>
|
|
</Card>
|
|
</Col>
|
|
|
|
<Col xs={24} lg={17}>
|
|
<Card
|
|
title={selectedProject ? selectedProject.name : '素材资产'}
|
|
extra={(
|
|
<Space wrap>
|
|
<Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(assetPage, assetPageSize); }} loading={assetLoading}>刷新</Button>
|
|
<Button type="primary" icon={<UploadOutlined />} disabled={!selectedProjectId} onClick={() => setUploadOpen(true)}>上传图片/视频</Button>
|
|
{selectedProjectId && (
|
|
<Popconfirm title="确认删除当前虚拟人像项目组吗?" onConfirm={handleDeleteProject}>
|
|
<Button danger icon={<DeleteOutlined />}>删除项目组</Button>
|
|
</Popconfirm>
|
|
)}
|
|
</Space>
|
|
)}
|
|
style={{ borderRadius: 16, minHeight: 520 }}
|
|
>
|
|
<Space style={{ width: '100%', marginBottom: 16 }} wrap>
|
|
<Input.Search
|
|
allowClear
|
|
placeholder="搜索素材名称"
|
|
value={keyword}
|
|
onChange={(e) => setKeyword(e.target.value)}
|
|
onSearch={() => loadAssets(1, assetPageSize)}
|
|
style={{ width: 240 }}
|
|
/>
|
|
<Select
|
|
allowClear
|
|
placeholder="素材状态"
|
|
value={assetStatus}
|
|
onChange={(value) => setAssetStatus(value)}
|
|
style={{ width: 150 }}
|
|
options={[
|
|
{ value: 'Processing', label: '处理中' },
|
|
{ value: 'Active', label: '可用' },
|
|
{ value: 'Failed', label: '失败' },
|
|
]}
|
|
/>
|
|
<Select
|
|
allowClear
|
|
placeholder="素材类型"
|
|
value={assetType}
|
|
onChange={(value) => setAssetType(value)}
|
|
style={{ width: 130 }}
|
|
options={[
|
|
{ value: 'Image', label: '图片' },
|
|
{ value: 'Video', label: '视频' },
|
|
]}
|
|
/>
|
|
<Button onClick={() => loadAssets(1, assetPageSize)}>筛选</Button>
|
|
</Space>
|
|
|
|
<Spin spinning={assetLoading}>
|
|
{!selectedProjectId ? (
|
|
<Empty description="请先创建或选择项目组" style={{ marginTop: 80 }} />
|
|
) : assets.length === 0 ? (
|
|
<Empty description="暂无素材,上传图片/视频后会异步入库" style={{ marginTop: 80 }} />
|
|
) : (
|
|
<>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 14 }}>
|
|
{assets.map(renderAssetCard)}
|
|
</div>
|
|
<div style={{ textAlign: 'right', marginTop: 16 }}>
|
|
<Pagination
|
|
current={assetPage}
|
|
pageSize={assetPageSize}
|
|
total={assetTotal}
|
|
showSizeChanger
|
|
showTotal={(value) => `共 ${value} 个素材`}
|
|
onChange={(page, size) => loadAssets(page, size)}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</Spin>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Modal
|
|
title="创建虚拟人像项目组"
|
|
open={createOpen}
|
|
onCancel={() => setCreateOpen(false)}
|
|
onOk={handleCreateProject}
|
|
confirmLoading={creatingProject}
|
|
okText="创建并同步火山 Asset Group"
|
|
>
|
|
<Form form={createForm} layout="vertical">
|
|
<Form.Item name="name" label="项目组名称" rules={[{ required: true, message: '请输入项目组名称' }]}>
|
|
<Input placeholder="例如:虚拟主播A / 品牌代言人B" maxLength={128} />
|
|
</Form.Item>
|
|
<Form.Item name="description" label="描述">
|
|
<Input.TextArea placeholder="可填写人物设定、服装风格、素材要求等" maxLength={2000} rows={4} />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
<Modal
|
|
title="上传虚拟人像素材"
|
|
open={uploadOpen}
|
|
onCancel={() => setUploadOpen(false)}
|
|
onOk={handleUpload}
|
|
confirmLoading={uploading}
|
|
okText="提交入库"
|
|
>
|
|
<Space direction="vertical" style={{ width: '100%' }} size={14}>
|
|
<Input value={assetName} onChange={(e) => setAssetName(e.target.value)} placeholder="素材名称,默认使用文件名" maxLength={256} />
|
|
<Upload
|
|
accept="image/*,video/*"
|
|
maxCount={1}
|
|
fileList={fileList}
|
|
beforeUpload={() => false}
|
|
onChange={({ fileList: next }) => setFileList(next)}
|
|
listType="picture"
|
|
>
|
|
<Button icon={<UploadOutlined />}>选择图片或视频</Button>
|
|
</Upload>
|
|
<div style={{ padding: 12, background: '#f8fafc', borderRadius: 12, color: '#64748b', fontSize: 13 }}>
|
|
当前开放图片和视频,音频暂不接入。提交后会调用火山 CreateAsset 异步处理,状态变为 Active 后才可用于 AI 创作。
|
|
</div>
|
|
</Space>
|
|
</Modal>
|
|
|
|
<Modal title="素材预览" open={previewOpen} onCancel={() => setPreviewOpen(false)} footer={null} width={760} destroyOnClose>
|
|
<div style={{ minHeight: 420, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0f172a', borderRadius: 12, overflow: 'hidden' }}>
|
|
{previewType === 'Video' ? (
|
|
<video src={previewUrl} controls autoPlay style={{ maxWidth: '100%', maxHeight: 520 }} />
|
|
) : (
|
|
<img src={previewUrl} alt="素材预览" style={{ maxWidth: '100%', maxHeight: 520, objectFit: 'contain' }} />
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PrivatePortraitVirtualMaterialPage;
|