虚拟素材库集成到真人素材库

This commit is contained in:
2026-07-07 17:42:31 +08:00
parent 141a2051ba
commit 35ffa317e7
10 changed files with 792 additions and 737 deletions
@@ -1,2 +1,4 @@
export { default as PrivatePortraitLibraryPanel } from './library/LibraryPanel';
export { default as PrivatePortraitRealPersonLibraryPanel } from './library/RealPersonLibraryPanel';
export { default as PrivatePortraitVirtualMaterialPanel } from './library/VirtualMaterialPanel';
export { default as PrivatePortraitAssetPicker } from './picker/AssetPicker';
@@ -1,187 +1,56 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Card, Col, Form, Input, Modal, QRCode, Row, Space, Spin, Typography, message } from 'antd';
import { CheckCircleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import type { PrivatePortraitProject, PrivatePortraitValidateSession } from '../../../types';
import { createPrivatePortraitProject, getPrivatePortraitProjects, getPrivatePortraitValidateSession } from '../../../api';
import PrivatePortraitProjectList from './ProjectList';
import PrivatePortraitProjectDetail from './ProjectDetail';
import React, { useMemo, useState } from 'react';
import { Tabs, Typography } from 'antd';
import { useSearchParams } from 'react-router-dom';
import RealPersonLibraryPanel from './RealPersonLibraryPanel';
import VirtualMaterialPanel from './VirtualMaterialPanel';
const VALIDATE_SUCCESS_STATUS = 'group_active';
const POLL_INTERVAL_FALLBACK = 2000;
const { Title, Text } = Typography;
type PrivatePortraitTabKey = 'real_person' | 'aigc_virtual';
const normalizeTabKey = (value?: string | null): PrivatePortraitTabKey => (
value === 'aigc_virtual' ? 'aigc_virtual' : 'real_person'
);
const PrivatePortraitLibraryPanel: React.FC = () => {
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [selected, setSelected] = useState<PrivatePortraitProject | null>(null);
const [loading, setLoading] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [createdProject, setCreatedProject] = useState<PrivatePortraitProject | null>(null);
const [validateSession, setValidateSession] = useState<PrivatePortraitValidateSession | null>(null);
const [polling, setPolling] = useState(false);
const [form] = Form.useForm();
const timerRef = useRef<number | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const [activeKey, setActiveKey] = useState<PrivatePortraitTabKey>(() => normalizeTabKey(searchParams.get('portraitTab')));
const clearPollTimer = () => {
if (timerRef.current) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
const items = useMemo(() => [
{
key: 'real_person',
label: '真人素材',
children: <RealPersonLibraryPanel />,
},
{
key: 'aigc_virtual',
label: '虚拟素材',
children: <VirtualMaterialPanel />,
},
], []);
const handleTabChange = (key: string) => {
const nextKey = normalizeTabKey(key);
setActiveKey(nextKey);
const nextParams = new URLSearchParams(searchParams);
nextParams.set('filterType', 'private_portrait');
nextParams.set('portraitTab', nextKey);
setSearchParams(nextParams, { replace: true });
};
const resetCreateModal = () => {
clearPollTimer();
setCreateOpen(false);
setCreating(false);
setPolling(false);
setCreatedProject(null);
setValidateSession(null);
form.resetFields();
};
const loadProjects = async () => {
setLoading(true);
try {
const res = await getPrivatePortraitProjects({ pageSize: 100, status: 'active' });
setProjects(res.items);
setSelected((prev) => prev ? (res.items.find((item) => item.id === prev.id) || res.items[0] || null) : (res.items[0] || null));
} catch (e: any) {
message.error(e?.message || '加载真人素材项目组失败');
} finally {
setLoading(false);
}
};
useEffect(() => { loadProjects(); }, []);
useEffect(() => () => clearPollTimer(), []);
const finishCreateSuccess = async (projectId: string) => {
clearPollTimer();
setPolling(false);
message.success('真人认证完成,项目组已创建成功');
setCreateOpen(false);
setValidateSession(null);
setCreatedProject(null);
form.resetFields();
const res = await getPrivatePortraitProjects({ pageSize: 100, status: 'active' });
setProjects(res.items);
setSelected(res.items.find((item) => item.id === projectId) || res.items[0] || null);
};
const startPolling = (sessionId: string, projectId: string, intervalMs: number) => {
clearPollTimer();
setPolling(true);
const run = async () => {
try {
const next = await getPrivatePortraitValidateSession(sessionId);
setValidateSession(next);
if (next.status === VALIDATE_SUCCESS_STATUS) {
await finishCreateSuccess(projectId);
return;
}
if (['callback_failed', 'failed', 'expired'].includes(next.status)) {
clearPollTimer();
setPolling(false);
message.error(next.errorMessage || '真人认证未完成,请重新创建项目组');
}
} catch (e: any) {
clearPollTimer();
setPolling(false);
message.error(e?.message || '轮询真人认证状态失败');
}
};
timerRef.current = window.setInterval(run, Math.max(1000, intervalMs || POLL_INTERVAL_FALLBACK));
void run();
};
const handleCreate = async () => {
const values = await form.validateFields();
setCreating(true);
try {
const callbackRedirectUrl = `${window.location.origin}/private-portrait-authorized`;
const res = await createPrivatePortraitProject({ ...values, callbackRedirectUrl });
setCreatedProject(res.project);
setValidateSession(res.validateSession);
message.success('请使用手机扫码完成人脸认证');
if (res.validateSession?.id) {
startPolling(res.validateSession.id, res.project.id, res.pollIntervalMs || POLL_INTERVAL_FALLBACK);
}
} catch (e: any) {
message.error(e?.message || '创建项目组认证二维码失败');
} finally {
setCreating(false);
}
};
const h5Link = validateSession?.h5Link || '';
const isSuccess = validateSession?.status === VALIDATE_SUCCESS_STATUS;
return (
<div style={{ padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={loadProjects} loading={loading}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>
</Space>
<div style={{ marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Text type="secondary"> AIGC Asset Group</Text>
</div>
<Row gutter={16}>
<Col xs={24} md={7} lg={6}>
<Card title="项目组" style={{ borderRadius: 12 }}>
<PrivatePortraitProjectList items={projects} selectedId={selected?.id} onSelect={setSelected} />
</Card>
</Col>
<Col xs={24} md={17} lg={18}>
{selected ? (
<PrivatePortraitProjectDetail project={selected} onDeleted={() => { setSelected(null); loadProjects(); }} onChanged={loadProjects} />
) : (
<Card style={{ borderRadius: 12, textAlign: 'center', color: '#94a3b8' }}></Card>
)}
</Col>
</Row>
<Modal
title="新建真人素材项目组"
open={createOpen}
onCancel={resetCreateModal}
footer={validateSession ? [<Button key="close" onClick={resetCreateModal}></Button>] : undefined}
onOk={validateSession ? undefined : handleCreate}
okText="开始认证并创建"
confirmLoading={creating}
maskClosable={!polling}
>
{!validateSession ? (
<Form form={form} layout="vertical">
<Form.Item label="项目组名称" name="name" rules={[{ required: true, message: '请输入项目组名称' }]}>
<Input placeholder="例如:达人A、客户B、张三人像" />
</Form.Item>
<Form.Item label="描述" name="description">
<Input.TextArea rows={3} placeholder="可选" />
</Form.Item>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
</Typography.Paragraph>
</Form>
) : (
<Space direction="vertical" align="center" size={16} style={{ width: '100%' }}>
{isSuccess ? (
<CheckCircleOutlined style={{ fontSize: 54, color: '#22c55e' }} />
) : h5Link ? (
<QRCode value={h5Link} size={220} />
) : (
<Spin />
)}
<div style={{ textAlign: 'center' }}>
<Typography.Title level={5} style={{ marginBottom: 8 }}>{createdProject?.name || '真人素材项目组'}</Typography.Title>
<Typography.Text type={isSuccess ? 'success' : 'secondary'}>
{isSuccess ? '认证成功,项目组正在刷新' : '请使用手机扫码完成人脸认证,成功后回到电脑端查看项目组。'}
</Typography.Text>
</div>
{h5Link && !isSuccess && <Typography.Text copyable style={{ wordBreak: 'break-all' }}>{h5Link}</Typography.Text>}
</Space>
)}
</Modal>
<Tabs
activeKey={activeKey}
onChange={handleTabChange}
items={items}
destroyInactiveTabPane={false}
/>
</div>
);
};
@@ -0,0 +1,189 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Card, Col, Form, Input, Modal, QRCode, Row, Space, Spin, Typography, message } from 'antd';
import { CheckCircleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import type { PrivatePortraitProject, PrivatePortraitValidateSession } from '../../../types';
import { createPrivatePortraitProject, getPrivatePortraitProjects, getPrivatePortraitValidateSession } from '../../../api';
import PrivatePortraitProjectList from './ProjectList';
import PrivatePortraitProjectDetail from './ProjectDetail';
const VALIDATE_SUCCESS_STATUS = 'group_active';
const POLL_INTERVAL_FALLBACK = 2000;
const RealPersonLibraryPanel: React.FC = () => {
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [selected, setSelected] = useState<PrivatePortraitProject | null>(null);
const [loading, setLoading] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [createdProject, setCreatedProject] = useState<PrivatePortraitProject | null>(null);
const [validateSession, setValidateSession] = useState<PrivatePortraitValidateSession | null>(null);
const [polling, setPolling] = useState(false);
const [form] = Form.useForm();
const timerRef = useRef<number | null>(null);
const clearPollTimer = () => {
if (timerRef.current) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
};
const resetCreateModal = () => {
clearPollTimer();
setCreateOpen(false);
setCreating(false);
setPolling(false);
setCreatedProject(null);
setValidateSession(null);
form.resetFields();
};
const loadProjects = async () => {
setLoading(true);
try {
const res = await getPrivatePortraitProjects({ pageSize: 100, status: 'active' });
setProjects(res.items);
setSelected((prev) => prev ? (res.items.find((item) => item.id === prev.id) || res.items[0] || null) : (res.items[0] || null));
} catch (e: any) {
message.error(e?.message || '加载真人素材项目组失败');
} finally {
setLoading(false);
}
};
useEffect(() => { loadProjects(); }, []);
useEffect(() => () => clearPollTimer(), []);
const finishCreateSuccess = async (projectId: string) => {
clearPollTimer();
setPolling(false);
message.success('真人认证完成,项目组已创建成功');
setCreateOpen(false);
setValidateSession(null);
setCreatedProject(null);
form.resetFields();
const res = await getPrivatePortraitProjects({ pageSize: 100, status: 'active' });
setProjects(res.items);
setSelected(res.items.find((item) => item.id === projectId) || res.items[0] || null);
};
const startPolling = (sessionId: string, projectId: string, intervalMs: number) => {
clearPollTimer();
setPolling(true);
const run = async () => {
try {
const next = await getPrivatePortraitValidateSession(sessionId);
setValidateSession(next);
if (next.status === VALIDATE_SUCCESS_STATUS) {
await finishCreateSuccess(projectId);
return;
}
if (['callback_failed', 'failed', 'expired'].includes(next.status)) {
clearPollTimer();
setPolling(false);
message.error(next.errorMessage || '真人认证未完成,请重新创建项目组');
}
} catch (e: any) {
clearPollTimer();
setPolling(false);
message.error(e?.message || '轮询真人认证状态失败');
}
};
timerRef.current = window.setInterval(run, Math.max(1000, intervalMs || POLL_INTERVAL_FALLBACK));
void run();
};
const handleCreate = async () => {
const values = await form.validateFields();
setCreating(true);
try {
const callbackRedirectUrl = `${window.location.origin}/private-portrait-authorized`;
const res = await createPrivatePortraitProject({ ...values, callbackRedirectUrl });
setCreatedProject(res.project);
setValidateSession(res.validateSession);
message.success('请使用手机扫码完成人脸认证');
if (res.validateSession?.id) {
startPolling(res.validateSession.id, res.project.id, res.pollIntervalMs || POLL_INTERVAL_FALLBACK);
}
} catch (e: any) {
message.error(e?.message || '创建项目组认证二维码失败');
} finally {
setCreating(false);
}
};
const h5Link = validateSession?.h5Link || '';
const isSuccess = validateSession?.status === VALIDATE_SUCCESS_STATUS;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={loadProjects} loading={loading}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>
</Space>
</div>
<Row gutter={16}>
<Col xs={24} md={7} lg={6}>
<Card title="项目组" style={{ borderRadius: 12 }}>
<PrivatePortraitProjectList items={projects} selectedId={selected?.id} onSelect={setSelected} />
</Card>
</Col>
<Col xs={24} md={17} lg={18}>
{selected ? (
<PrivatePortraitProjectDetail project={selected} onDeleted={() => { setSelected(null); loadProjects(); }} onChanged={loadProjects} />
) : (
<Card style={{ borderRadius: 12, textAlign: 'center', color: '#94a3b8' }}></Card>
)}
</Col>
</Row>
<Modal
title="新建真人素材项目组"
open={createOpen}
onCancel={resetCreateModal}
footer={validateSession ? [<Button key="close" onClick={resetCreateModal}></Button>] : undefined}
onOk={validateSession ? undefined : handleCreate}
okText="开始认证并创建"
confirmLoading={creating}
maskClosable={!polling}
>
{!validateSession ? (
<Form form={form} layout="vertical">
<Form.Item label="项目组名称" name="name" rules={[{ required: true, message: '请输入项目组名称' }]}>
<Input placeholder="例如:达人A、客户B、张三人像" />
</Form.Item>
<Form.Item label="描述" name="description">
<Input.TextArea rows={3} placeholder="可选" />
</Form.Item>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
</Typography.Paragraph>
</Form>
) : (
<Space direction="vertical" align="center" size={16} style={{ width: '100%' }}>
{isSuccess ? (
<CheckCircleOutlined style={{ fontSize: 54, color: '#22c55e' }} />
) : h5Link ? (
<QRCode value={h5Link} size={220} />
) : (
<Spin />
)}
<div style={{ textAlign: 'center' }}>
<Typography.Title level={5} style={{ marginBottom: 8 }}>{createdProject?.name || '真人素材项目组'}</Typography.Title>
<Typography.Text type={isSuccess ? 'success' : 'secondary'}>
{isSuccess ? '认证成功,项目组正在刷新' : '请使用手机扫码完成人脸认证,成功后回到电脑端查看项目组。'}
</Typography.Text>
</div>
{h5Link && !isSuccess && <Typography.Text copyable style={{ wordBreak: 'break-all' }}>{h5Link}</Typography.Text>}
</Space>
)}
</Modal>
</div>
);
};
export default RealPersonLibraryPanel;
@@ -0,0 +1,621 @@
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 {
CloudSyncOutlined,
DeleteOutlined,
EyeOutlined,
PictureOutlined,
PlusOutlined,
ReloadOutlined,
UploadOutlined,
VideoCameraOutlined,
} from '@ant-design/icons';
import {
createPrivatePortraitVirtualAsset,
createPrivatePortraitVirtualProject,
deletePrivatePortraitVirtualAsset,
deletePrivatePortraitVirtualProject,
getPrivatePortraitVirtualAssets,
getPrivatePortraitVirtualConfig,
getPrivatePortraitVirtualProjects,
syncPrivatePortraitVirtualAsset,
uploadImage,
uploadVideo,
} from '../../../api';
import type { PrivatePortraitAsset, PrivatePortraitConfig, PrivatePortraitProject } from '../../../types';
const { Text, Paragraph } = Typography;
type AssetTypeFilter = 'Image' | 'Video' | undefined;
const MIN_PRIVATE_VIDEO_DURATION = 2;
const MAX_PRIVATE_VIDEO_DURATION = 15;
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) => {
if (asset.assetType === 'Video') {
return buildPreviewUrl(asset.videoCoverUrl || asset.previewUrl || asset.displayUrl || asset.remoteUrl || asset.sourceUrl);
}
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 validatePrivateVideoDuration = (duration: number | null, showMessage: (content: string) => void): duration is number => {
if (duration == null || !Number.isFinite(duration) || duration <= 0) {
showMessage('无法读取视频秒数,请检查视频文件是否损坏');
return false;
}
if (duration < MIN_PRIVATE_VIDEO_DURATION) {
showMessage(`视频素材最短不能少于 ${MIN_PRIVATE_VIDEO_DURATION}`);
return false;
}
if (duration > MAX_PRIVATE_VIDEO_DURATION) {
showMessage(`视频素材最长不能超过 ${MAX_PRIVATE_VIDEO_DURATION}`);
return false;
}
return true;
};
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 VirtualMaterialPanel: React.FC = () => {
const { message } = App.useApp();
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(() => {
void 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);
if (currentType === 'Image' && !file.type.startsWith('image/')) {
message.error('仅支持图片或视频素材');
return;
}
setUploading(true);
try {
const duration = currentType === 'Video' ? await getVideoDuration(file) : null;
if (currentType === 'Video' && !validatePrivateVideoDuration(duration, message.error)) {
return;
}
const uploaded = currentType === 'Video' ? await uploadVideo(file) : await uploadImage(file);
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>
<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 }}>
{MIN_PRIVATE_VIDEO_DURATION}~{MAX_PRIVATE_VIDEO_DURATION} 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 VirtualMaterialPanel;