真人人像修改

This commit is contained in:
sjy
2026-07-08 17:15:19 +08:00
parent 35ffa317e7
commit bb53063358
17 changed files with 1320 additions and 679 deletions
@@ -1,6 +1,6 @@
import React from 'react';
import { Button, Empty, Popconfirm, Space, Tag, Tooltip } from 'antd';
import { DeleteOutlined, PictureOutlined, ReloadOutlined, VideoCameraOutlined } from '@ant-design/icons';
import React, { useEffect, useRef, useState } from 'react';
import { Button, Card, Empty, Modal, Popconfirm, Space, Tag, Tooltip } from 'antd';
import { DeleteOutlined, EyeOutlined, PictureOutlined, VideoCameraOutlined } from '@ant-design/icons';
import type { PrivatePortraitAsset } from '../../../types';
const statusColor: Record<string, string> = {
@@ -12,11 +12,20 @@ const statusColor: Record<string, string> = {
delete_failed: 'red',
};
const statusText: Record<string, string> = {
Active: '入库成功',
Processing: '入库处理中',
Failed: '入库失败',
local_deleted: '本地已删除',
remote_deleted: '远程已删除',
delete_failed: '删除失败',
};
interface Props {
items: PrivatePortraitAsset[];
loading?: boolean;
onSync: (assetId: string) => void;
onDelete: (assetId: string) => void;
onRefresh?: () => void;
}
const buildPreviewUrl = (url?: string | null) => {
@@ -39,53 +48,114 @@ const formatDuration = (value?: number | null) => {
return `${duration.toFixed(duration >= 10 ? 0 : 1)}s`;
};
const PrivatePortraitAssetGrid: React.FC<Props> = ({ items, onSync, onDelete }) => {
if (!items.length) return <Empty description="暂无真人素材" />;
const PrivatePortraitAssetGrid: React.FC<Props> = ({ items, onDelete, onRefresh }) => {
const pollingRef = useRef<number | null>(null);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewUrl, setPreviewUrl] = useState('');
const [previewType, setPreviewType] = useState<'Image' | 'Video'>('Image');
useEffect(() => {
const needsPolling = items.some(
(item) => item.status !== 'Failed' && item.status !== 'Active'
);
if (needsPolling && onRefresh) {
if (!pollingRef.current) {
pollingRef.current = window.setInterval(() => {
onRefresh();
}, 3000);
}
} else {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
}
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
};
}, [items, onRefresh]);
const openPreview = (asset: PrivatePortraitAsset) => {
const url = getAssetPreviewUrl(asset);
if (!url) return;
setPreviewUrl(url);
setPreviewType(asset.assetType === 'Video' ? 'Video' : 'Image');
setPreviewOpen(true);
};
if (!items.length) return <Empty description="暂无素材" />;
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 14 }}>
{items.map((item) => {
const isVideo = item.assetType === 'Video';
const previewUrl = getAssetPreviewUrl(item);
return (
<div key={item.id} style={{ border: '1px solid #e2e8f0', borderRadius: 12, overflow: 'hidden', background: '#fff' }}>
<div style={{ height: 150, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
{previewUrl ? (
isVideo ? (
<video src={previewUrl} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<img src={previewUrl} alt={item.name || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
)
) : isVideo ? (
<VideoCameraOutlined style={{ fontSize: 32, color: '#94a3b8' }} />
) : (
<PictureOutlined style={{ fontSize: 32, color: '#94a3b8' }} />
)}
{isVideo && (
<div style={{ position: 'absolute', left: 8, bottom: 8, padding: '2px 6px', borderRadius: 8, background: 'rgba(15,23,42,0.72)', color: '#fff', fontSize: 12 }}>
{formatDuration(item.videoDuration)}
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 14 }}>
{items.map((item) => {
const isVideo = item.assetType === 'Video';
const previewUrl = getAssetPreviewUrl(item);
return (
<Card
key={item.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' }}>
{previewUrl ? (
isVideo && item.videoCoverUrl ? (
<img src={previewUrl} alt={item.name || '视频封面'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : isVideo ? (
<video src={previewUrl} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<img src={previewUrl} alt={item.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>}
{previewUrl && (
<Button size="small" shape="circle" icon={<EyeOutlined />} style={{ position: 'absolute', right: 10, top: 10 }} onClick={() => openPreview(item)} />
)}
{isVideo && (
<div style={{ position: 'absolute', left: 10, bottom: 10, padding: '2px 6px', borderRadius: 8, background: 'rgba(15,23,42,0.72)', color: '#fff', fontSize: 12 }}>
{formatDuration(item.videoDuration)}
</div>
)}
</div>
)}
</div>
<div style={{ padding: 10 }}>
<Tooltip title={item.name || item.remoteAssetId}>
<div style={{ fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.name || item.remoteAssetId}</div>
</Tooltip>
<Space wrap size={4} style={{ marginTop: 8 }}>
<Tag color={statusColor[item.status] || 'default'}>{item.status}</Tag>
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Tooltip title={item.name || item.remoteAssetId || item.id}>
<div style={{ fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.name || item.remoteAssetId || '未命名素材'}</div>
</Tooltip>
<Space wrap size={4}>
<Tag color={statusColor[item.status] || 'default'}>{statusText[item.status] || item.status}</Tag>
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
</Space>
<Space size={6} wrap>
<Popconfirm title="确认删除这个素材吗?" onConfirm={() => onDelete(item.id)}>
<Button size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
</Space>
{item.errorMessage && <div style={{ color: '#ef4444', fontSize: 12, marginTop: 6 }}>{item.errorMessage}</div>}
<Space style={{ marginTop: 10 }} size={6}>
<Button size="small" icon={<ReloadOutlined />} onClick={() => onSync(item.id)}></Button>
<Popconfirm title="确认删除这个素材吗?" onConfirm={() => onDelete(item.id)}>
<Button size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
</div>
</div>
);
})}
</div>
</Card>
);
})}
</div>
<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>
</>
);
};
@@ -1,10 +1,12 @@
import React, { useMemo, useState } from 'react';
import { Tabs, Typography } from 'antd';
import React, { useMemo, useState, useEffect } from 'react';
import { Card, Col, Row, Tabs, Typography, message } from 'antd';
import { useSearchParams } from 'react-router-dom';
import { getPrivatePortraitConfig, getPrivatePortraitProjects, getPrivatePortraitVirtualConfig, getPrivatePortraitVirtualProjects } from '../../../api';
import type { PrivatePortraitConfig, PrivatePortraitProject } from '../../../types';
import RealPersonLibraryPanel from './RealPersonLibraryPanel';
import VirtualMaterialPanel from './VirtualMaterialPanel';
const { Title, Text } = Typography;
const { Title, Text, Paragraph } = Typography;
type PrivatePortraitTabKey = 'real_person' | 'aigc_virtual';
@@ -15,6 +17,48 @@ const normalizeTabKey = (value?: string | null): PrivatePortraitTabKey => (
const PrivatePortraitLibraryPanel: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [activeKey, setActiveKey] = useState<PrivatePortraitTabKey>(() => normalizeTabKey(searchParams.get('portraitTab')));
const [config, setConfig] = useState<PrivatePortraitConfig | null>(null);
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string>();
useEffect(() => {
const loadData = async () => {
try {
if (activeKey === 'aigc_virtual') {
const [configRes, projectsRes] = await Promise.all([
getPrivatePortraitVirtualConfig(),
getPrivatePortraitVirtualProjects({ page: 1, pageSize: 100, status: 'active' }),
]);
setConfig(configRes);
const projectList = projectsRes.items || [];
setProjects(projectList);
setSelectedProjectId((prev) => prev && projectList.some((item) => item.id === prev) ? prev : projectList[0]?.id);
} else {
const [configRes, projectsRes] = await Promise.all([
getPrivatePortraitConfig(),
getPrivatePortraitProjects({ pageSize: 100, status: 'active' }),
]);
setConfig(configRes);
const projectList = projectsRes.items || [];
setProjects(projectList);
setSelectedProjectId((prev) => prev && projectList.some((item) => item.id === prev) ? prev : projectList[0]?.id);
}
} catch (err: any) {
message.error(err?.message || '加载数据失败');
}
};
void loadData();
}, [activeKey]);
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 items = useMemo(() => [
{
@@ -45,6 +89,33 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
<Title level={4} style={{ margin: 0 }}></Title>
<Text type="secondary"> AIGC Asset Group</Text>
</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' }}>
{activeKey === 'aigc_virtual'
? '虚拟人像项目会同步创建火山 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>
<Tabs
activeKey={activeKey}
onChange={handleTabChange}
@@ -1,8 +1,8 @@
import React, { useEffect, useState } from 'react';
import { Button, Card, Popconfirm, Space, Tag, Typography, message } from 'antd';
import { DeleteOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons';
import { Button, Card, Empty, Input, Pagination, Popconfirm, Select, Space, Spin, Typography, message } from 'antd';
import { DeleteOutlined, UploadOutlined } from '@ant-design/icons';
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types';
import { deletePrivatePortraitAsset, deletePrivatePortraitProject, getPrivatePortraitAssets, syncPrivatePortraitAsset } from '../../../api';
import { deletePrivatePortraitAsset, deletePrivatePortraitProject, getPrivatePortraitAssets } from '../../../api';
import PrivatePortraitAssetGrid from './AssetGrid';
import PrivatePortraitAssetUpload from './AssetUpload';
@@ -16,12 +16,27 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
const [loading, setLoading] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
const [keyword, setKeyword] = useState('');
const [assetStatus, setAssetStatus] = useState<string>();
const [assetType, setAssetType] = useState<string>();
const [assetPage, setAssetPage] = useState(1);
const [assetPageSize, setAssetPageSize] = useState(20);
const [assetTotal, setAssetTotal] = useState(0);
const loadAssets = async () => {
const loadAssets = async (page = assetPage, pageSize = assetPageSize) => {
setLoading(true);
try {
const res = await getPrivatePortraitAssets(project.id, { pageSize: 100 });
const res = await getPrivatePortraitAssets(project.id, {
page,
pageSize,
keyword,
status: assetStatus,
assetType: assetType as any,
});
setAssets(res.items);
setAssetTotal(res.total || 0);
setAssetPage(page);
setAssetPageSize(pageSize);
} catch (e: any) {
message.error(e?.message || '加载素材失败');
} finally {
@@ -29,23 +44,12 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
}
};
useEffect(() => { loadAssets(); }, [project.id]);
const handleSync = async (assetId: string) => {
try {
await syncPrivatePortraitAsset(assetId);
await loadAssets();
onChanged();
message.success('素材状态已刷新');
} catch (e: any) {
message.error(e?.message || '刷新失败');
}
};
useEffect(() => { loadAssets(1, assetPageSize); }, [project.id, keyword, assetStatus, assetType]);
const handleDeleteAsset = async (assetId: string) => {
try {
await deletePrivatePortraitAsset(assetId);
await loadAssets();
await loadAssets(assetPage, assetPageSize);
onChanged();
message.success('素材已删除');
} catch (e: any) {
@@ -67,26 +71,76 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
return (
<Card
title={<Space><span>{project.name}</span><Tag color={canUpload ? 'green' : 'processing'}>{project.status}</Tag></Space>}
title={<Space><span>{project.name}</span></Space>}
extra={(
<Space>
<Button type="primary" icon={<UploadOutlined />} disabled={!canUpload} onClick={() => setUploadOpen(true)}></Button>
<Button icon={<ReloadOutlined />} onClick={loadAssets} loading={loading}></Button>
<Popconfirm title="确认删除这个真人素材项目组吗?" onConfirm={handleDeleteProject}>
<Button danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
)}
style={{ borderRadius: 12 }}
style={{ borderRadius: 16 }}
>
<Typography.Paragraph style={{ color: '#64748b' }}>{project.description || '暂无描述'}</Typography.Paragraph>
{!canUpload && (
<Typography.Paragraph style={{ color: '#f97316' }}>
</Typography.Paragraph>
)}
<PrivatePortraitAssetGrid items={assets} loading={loading} onSync={handleSync} onDelete={handleDeleteAsset} />
<PrivatePortraitAssetUpload projectId={project.id} open={uploadOpen} onClose={() => setUploadOpen(false)} onSuccess={() => { loadAssets(); onChanged(); }} />
<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={loading}>
{assets.length === 0 ? (
<Empty description="暂无素材,上传图片/视频后会异步入库" style={{ marginTop: 80 }} />
) : (
<>
<PrivatePortraitAssetGrid items={assets} loading={loading} onDelete={handleDeleteAsset} onRefresh={() => loadAssets(assetPage, assetPageSize)} />
<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>
<PrivatePortraitAssetUpload projectId={project.id} open={uploadOpen} onClose={() => setUploadOpen(false)} onSuccess={() => { loadAssets(1, assetPageSize); onChanged(); }} />
</Card>
);
};
@@ -1,7 +1,9 @@
import React from 'react';
import { Button, Empty, List, Tag } from 'antd';
import { Empty, Space, Tag, Typography } from 'antd';
import type { PrivatePortraitProject } from '../../../types';
const { Text } = Typography;
interface Props {
items: PrivatePortraitProject[];
selectedId?: string | null;
@@ -11,24 +13,40 @@ interface Props {
const PrivatePortraitProjectList: React.FC<Props> = ({ items, selectedId, onSelect }) => {
if (!items.length) return <Empty description="暂无项目组" />;
return (
<List
dataSource={items}
renderItem={(item) => (
<List.Item style={{ padding: 0, marginBottom: 8 }}>
<Button
block
onClick={() => onSelect(item)}
style={{ height: 'auto', padding: 12, textAlign: 'left', borderColor: selectedId === item.id ? '#8b5cf6' : '#e2e8f0' }}
<Space direction="vertical" style={{ width: '100%' }} size={10}>
{items.map((project) => {
const active = selectedId === project.id;
return (
<div
key={project.id}
onClick={() => onSelect(project)}
style={{
padding: 14,
borderRadius: 14,
cursor: 'pointer',
border: active ? '1px solid #8b5cf6' : '1px solid #e2e8f0',
background: active ? '#f5f3ff' : '#fff',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
<strong>{item.name}</strong>
<Tag color={item.activeAssetCount > 0 ? 'green' : 'default'}>{item.activeAssetCount}/{item.assetCount}</Tag>
</div>
{item.description && <div style={{ color: '#64748b', fontSize: 12, marginTop: 4 }}>{item.description}</div>}
</Button>
</List.Item>
)}
/>
<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>
{/* <Tag color={project.status === 'active' ? 'green' : 'processing'}>
{project.status === 'active' ? '可用' : project.status}
</Tag> */}
</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>
);
};
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Card, Col, Form, Input, Modal, QRCode, Row, Space, Spin, Typography, message } from 'antd';
import { Button, Card, Col, Empty, 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';
@@ -117,27 +117,36 @@ const RealPersonLibraryPanel: React.FC = () => {
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 style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.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} />
<Col xs={24} lg={7}>
<Card
title={<span></span>}
extra={(
<Space size={8}>
{/* <Button icon={<ReloadOutlined />} onClick={loadProjects} loading={loading} size="small">刷新</Button> */}
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)} size="small"></Button>
</Space>
)}
style={{ borderRadius: 16, minHeight: 520 }}
>
<Spin spinning={loading}>
{projects.length === 0 ? (
<Empty description="暂无项目组" />
) : (
<PrivatePortraitProjectList items={projects} selectedId={selected?.id} onSelect={setSelected} />
)}
</Spin>
</Card>
</Col>
<Col xs={24} md={17} lg={18}>
<Col xs={24} lg={17}>
{selected ? (
<PrivatePortraitProjectDetail project={selected} onDeleted={() => { setSelected(null); loadProjects(); }} onChanged={loadProjects} />
) : (
<Card style={{ borderRadius: 12, textAlign: 'center', color: '#94a3b8' }}></Card>
<Card style={{ borderRadius: 16, minHeight: 520, textAlign: 'center', color: '#94a3b8' }}></Card>
)}
</Col>
</Row>
@@ -178,7 +187,7 @@ const RealPersonLibraryPanel: React.FC = () => {
{isSuccess ? '认证成功,项目组正在刷新' : '请使用手机扫码完成人脸认证,成功后回到电脑端查看项目组。'}
</Typography.Text>
</div>
{h5Link && !isSuccess && <Typography.Text copyable style={{ wordBreak: 'break-all' }}>{h5Link}</Typography.Text>}
{/* {h5Link && !isSuccess && <Typography.Text copyable style={{ wordBreak: 'break-all' }}>{h5Link}</Typography.Text>} */}
</Space>
)}
</Modal>
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
App,
Button,
@@ -21,12 +21,10 @@ import {
} from 'antd';
import type { UploadFile } from 'antd/es/upload/interface';
import {
CloudSyncOutlined,
DeleteOutlined,
EyeOutlined,
PictureOutlined,
PlusOutlined,
ReloadOutlined,
UploadOutlined,
VideoCameraOutlined,
} from '@ant-design/icons';
@@ -36,15 +34,13 @@ import {
deletePrivatePortraitVirtualAsset,
deletePrivatePortraitVirtualProject,
getPrivatePortraitVirtualAssets,
getPrivatePortraitVirtualConfig,
getPrivatePortraitVirtualProjects,
syncPrivatePortraitVirtualAsset,
uploadImage,
uploadVideo,
} from '../../../api';
import type { PrivatePortraitAsset, PrivatePortraitConfig, PrivatePortraitProject } from '../../../types';
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types';
const { Text, Paragraph } = Typography;
const { Text } = Typography;
type AssetTypeFilter = 'Image' | 'Video' | undefined;
@@ -53,39 +49,18 @@ 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' },
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 formatDuration = (value?: number | null) => {
const duration = Number(value || 0);
if (!Number.isFinite(duration) || duration <= 0) return '-';
return `${duration.toFixed(duration >= 10 ? 0 : 1)}s`;
};
const buildPreviewUrl = (url?: string | null) => {
@@ -154,15 +129,8 @@ const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
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[]>([]);
@@ -184,26 +152,13 @@ const VirtualMaterialPanel: React.FC = () => {
const [previewUrl, setPreviewUrl] = useState('');
const [previewType, setPreviewType] = useState<'Image' | 'Video'>('Image');
const [createForm] = Form.useForm<{ name: string; description?: string }>();
const pollingRef = useRef<number | null>(null);
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 {
@@ -245,7 +200,7 @@ const VirtualMaterialPanel: React.FC = () => {
};
const reloadAll = async () => {
await Promise.all([loadConfig(), loadProjects()]);
await loadProjects();
};
useEffect(() => {
@@ -256,6 +211,32 @@ const VirtualMaterialPanel: React.FC = () => {
if (selectedProjectId) loadAssets(1, assetPageSize);
}, [selectedProjectId]);
useEffect(() => {
const needsPolling = assets.some(
(asset) => asset.status !== 'Failed' && asset.status !== 'Active'
);
if (needsPolling && selectedProjectId) {
if (!pollingRef.current) {
pollingRef.current = window.setInterval(() => {
loadAssets(assetPage, assetPageSize);
}, 3000);
}
} else {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
}
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
};
}, [assets, selectedProjectId, assetPage, assetPageSize]);
const handleCreateProject = async () => {
const values = await createForm.validateFields();
setCreatingProject(true);
@@ -308,7 +289,7 @@ const VirtualMaterialPanel: React.FC = () => {
setUploadOpen(false);
setFileList([]);
setAssetName('');
await Promise.all([loadConfig(), loadProjects(), loadAssets(1, assetPageSize)]);
await Promise.all([loadProjects(), loadAssets(1, assetPageSize)]);
} catch (err: any) {
message.error(err?.message || '上传素材失败');
} finally {
@@ -316,21 +297,11 @@ const VirtualMaterialPanel: React.FC = () => {
}
};
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)]);
await Promise.all([loadProjects(), loadAssets(assetPage, assetPageSize)]);
} catch (err: any) {
message.error(err?.message || '删除素材失败');
}
@@ -370,36 +341,40 @@ const VirtualMaterialPanel: React.FC = () => {
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' }} />
{preview ? (
isVideo && asset.videoCoverUrl ? (
<img src={preview} alt={asset.name || '视频封面'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : isVideo ? (
<video src={preview} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<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)} />
{preview && (
<Button size="small" shape="circle" icon={<EyeOutlined />} style={{ position: 'absolute', right: 10, top: 10 }} onClick={() => openPreview(asset)} />
)}
{isVideo && (
<div style={{ position: 'absolute', left: 10, bottom: 10, padding: '2px 6px', borderRadius: 8, background: 'rgba(15,23,42,0.72)', color: '#fff', fontSize: 12 }}>
{formatDuration(asset.videoDuration)}
</div>
)}
</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>
<div style={{ fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{asset.name || asset.remoteAssetId || '未命名素材'}</div>
</Tooltip>
<Space wrap size={4}>
<TypeTag type={asset.assetType} />
<StatusTag status={asset.status} />
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
</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>
@@ -411,30 +386,6 @@ const VirtualMaterialPanel: React.FC = () => {
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
@@ -466,13 +417,13 @@ const VirtualMaterialPanel: React.FC = () => {
<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} />
{/* <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>
{/* <Tag color="success">Active {project.activeAssetCount || 0}</Tag> */}
</Space>
</div>
);
@@ -488,7 +439,7 @@ const VirtualMaterialPanel: React.FC = () => {
title={selectedProject ? selectedProject.name : '素材资产'}
extra={(
<Space wrap>
<Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(assetPage, assetPageSize); }} loading={assetLoading}></Button>
{/* <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}>
@@ -20,6 +20,13 @@ interface PrivatePortraitAssetPickerProps {
maxCount?: number;
onClose: () => void;
onSelect: (assets: PrivatePortraitSelectableAsset[]) => void;
maxImageCount?: number;
maxVideoCount?: number;
usedImageCount?: number;
usedVideoCount?: number;
usedVideoDuration?: number;
maxVideoDuration?: number;
accept?: string;
}
const libraryMeta: Record<PrivatePortraitLibraryType, { title: string; empty: string; projectError: string; assetError: string; fallbackName: string }> = {
@@ -68,12 +75,19 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
maxCount = 20,
onClose,
onSelect,
maxImageCount,
maxVideoCount,
usedImageCount,
usedVideoCount,
usedVideoDuration,
maxVideoDuration,
accept,
}) => {
const meta = libraryMeta[libraryType];
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [projectId, setProjectId] = useState<string | undefined>();
const [keyword, setKeyword] = useState('');
const [assetType, setAssetType] = useState<AssetTypeFilter>();
const [assetType, setAssetType] = useState<AssetTypeFilter>(accept === 'image/*' ? 'Image' : undefined);
const [assets, setAssets] = useState<PrivatePortraitSelectableAsset[]>([]);
const [selectedAssets, setSelectedAssets] = useState<Map<string, PrivatePortraitSelectableAsset>>(new Map());
const [loadingProjects, setLoadingProjects] = useState(false);
@@ -89,8 +103,8 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
const next = res.items || [];
setProjects(next);
setProjectId((prev) => (prev && next.some((item) => item.id === prev) ? prev : next[0]?.id));
} catch (err: any) {
message.error(err?.message || meta.projectError);
} catch (err: unknown) {
message.error((err as { message?: string })?.message || meta.projectError);
} finally {
setLoadingProjects(false);
}
@@ -108,8 +122,8 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
pageSize: 100,
});
setAssets(res.items || []);
} catch (err: any) {
message.error(err?.message || meta.assetError);
} catch (err: unknown) {
message.error((err as { message?: string })?.message || meta.assetError);
} finally {
setLoadingAssets(false);
}
@@ -117,24 +131,32 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
useEffect(() => {
if (!open) return;
setSelectedAssets(new Map());
setKeyword('');
setAssetType(undefined);
setProjectId(undefined);
setAssets([]);
loadProjects();
setTimeout(() => {
setSelectedAssets(new Map());
setKeyword('');
setAssetType(undefined);
setProjectId(undefined);
setAssets([]);
loadProjects();
}, 0);
}, [open, libraryType]);
useEffect(() => {
if (!open) return;
loadAssets();
}, [open, projectId, assetType]);
setTimeout(() => {
loadAssets();
}, 0);
}, [open, projectId, assetType, libraryType]);
const toggle = (asset: PrivatePortraitSelectableAsset) => {
if (selectedIds.includes(asset.id)) {
message.info('该素材已添加');
return;
}
if (accept === 'image/*' && asset.assetType === 'Video') {
message.warning('当前仅支持选择图片素材');
return;
}
setSelectedAssets((prev) => {
const next = new Map(prev);
if (next.has(asset.id)) {
@@ -150,6 +172,20 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
});
};
const isExceeded = useMemo(() => {
const selectedImages = Array.from(selectedAssets.values()).filter(a => a.assetType === 'Image').length;
const selectedVideos = Array.from(selectedAssets.values()).filter(a => a.assetType === 'Video').length;
const selectedVideoDuration = Array.from(selectedAssets.values())
.filter(a => a.assetType === 'Video')
.reduce((sum, a) => sum + (a.videoDuration || 0), 0);
const maxAvailableImages = maxImageCount !== undefined && usedImageCount !== undefined ? maxImageCount - usedImageCount : Infinity;
const maxAvailableVideos = maxVideoCount !== undefined && usedVideoCount !== undefined ? maxVideoCount - usedVideoCount : Infinity;
const maxAvailableDuration = maxVideoDuration !== undefined && usedVideoDuration !== undefined ? maxVideoDuration - usedVideoDuration : Infinity;
return selectedImages > maxAvailableImages || selectedVideos > maxAvailableVideos || selectedVideoDuration > maxAvailableDuration;
}, [selectedAssets, maxImageCount, usedImageCount, maxVideoCount, usedVideoCount, maxVideoDuration, usedVideoDuration]);
const confirm = () => {
const selected = (Array.from(selectedAssets.values()) as PrivatePortraitSelectableAsset[]).filter((item) => !selectedIds.includes(item.id));
if (!selected.length) {
@@ -162,20 +198,54 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
return (
<Modal
title={meta.title}
title={`${meta.title}`}
open={open}
onCancel={onClose}
width={920}
destroyOnHidden
footer={[
<Button key="cancel" onClick={onClose}></Button>,
<Button key="ok" type="primary" onClick={confirm} style={{ background: '#8b5cf6' }}>
<Button key="ok" type="primary" onClick={confirm} disabled={isExceeded} style={{
background: isExceeded ? '#94a3b8' : '#8b5cf6',
opacity: isExceeded ? 0.6 : 1,
cursor: isExceeded ? 'not-allowed' : 'pointer',
}}>
{selectedAssets.size}
</Button>,
]}
>
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 16, minHeight: 480 }}>
<div style={{ border: '1px solid #eef0f4', borderRadius: 12, padding: 12, background: '#fafafa' }}>
{accept !== 'image/*' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 20, marginBottom: 16, padding: '12px 16px', background: '#fafafa', borderRadius: 8 }}>
{(() => {
const selectedImages = Array.from(selectedAssets.values()).filter(a => a.assetType === 'Image').length;
const selectedVideos = Array.from(selectedAssets.values()).filter(a => a.assetType === 'Video').length;
const selectedVideoDuration = Array.from(selectedAssets.values())
.filter(a => a.assetType === 'Video')
.reduce((sum, a) => sum + (a.videoDuration || 0), 0);
const maxAvailableImages = maxImageCount !== undefined && usedImageCount !== undefined ? maxImageCount - usedImageCount : Infinity;
const maxAvailableVideos = maxVideoCount !== undefined && usedVideoCount !== undefined ? maxVideoCount - usedVideoCount : Infinity;
const maxAvailableDuration = maxVideoDuration !== undefined && usedVideoDuration !== undefined ? maxVideoDuration - usedVideoDuration : Infinity;
const imageExceeded = selectedImages > maxAvailableImages;
const videoExceeded = selectedVideos > maxAvailableVideos;
const durationExceeded = selectedVideoDuration > maxAvailableDuration;
return (
<>
<span style={{ fontSize: 13, color: imageExceeded ? '#ef4444' : '#64748b', fontWeight: imageExceeded ? 600 : 400 }}>
{selectedImages}/{maxImageCount !== undefined && usedImageCount !== undefined ? maxImageCount - usedImageCount : '-'}
</span>
<span style={{ fontSize: 13, color: videoExceeded || durationExceeded ? '#ef4444' : '#64748b', fontWeight: videoExceeded || durationExceeded ? 600 : 400 }}>
{selectedVideos}/{maxVideoCount !== undefined && usedVideoCount !== undefined ? maxVideoCount - usedVideoCount : '-'} {selectedVideoDuration.toFixed(1)}/{maxVideoDuration !== undefined && usedVideoDuration !== undefined ? (maxVideoDuration - usedVideoDuration).toFixed(1) : '-'}
</span>
</>
);
})()}
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 16, height: 500, }}>
<div style={{ border: '1px solid #eef0f4', borderRadius: 12, padding: 12, background: '#fafafa',overflowY: 'auto', height: "100%" }}>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 12 }}>
<Text strong></Text>
<Button size="small" icon={<ReloadOutlined />} onClick={loadProjects} loading={loadingProjects} />
@@ -199,9 +269,9 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
<div style={{ width: '100%' }}>
<Text strong ellipsis style={{ display: 'block' }}>{item.name}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
Active {item.activeAssetCount || 0}
{/* {item.activeAssetCount || 0} */}
{typeof item.activeImageAssetCount === 'number' || typeof item.activeVideoAssetCount === 'number'
? ` ·${item.activeImageAssetCount || 0} / 视 ${item.activeVideoAssetCount || 0}`
? `${item.activeImageAssetCount || 0} / 视 ${item.activeVideoAssetCount || 0}`
: ''}
</Text>
</div>
@@ -211,7 +281,7 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
</Spin>
</div>
<div>
<div style={{overflowY: 'auto', height: "100%" }}>
<Space style={{ width: '100%', marginBottom: 12 }}>
<Input
allowClear
@@ -222,12 +292,14 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
onPressEnter={loadAssets}
/>
<Select
allowClear
allowClear={accept !== 'image/*'}
placeholder="素材类型"
value={assetType}
onChange={setAssetType}
style={{ width: 116 }}
options={[
options={accept === 'image/*' ? [
{ value: 'Image', label: '图片' },
] : [
{ value: 'Image', label: '图片' },
{ value: 'Video', label: '视频' },
]}
@@ -239,7 +311,7 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={meta.empty} style={{ marginTop: 120 }} />
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 12, maxHeight: 420, overflowY: 'auto', paddingRight: 4 }}>
{assets.map((asset) => {
{assets.filter(asset => accept !== 'image/*' || asset.assetType === 'Image').map((asset) => {
const active = checked.has(asset.id) || selectedIds.includes(asset.id);
const disabled = selectedIds.includes(asset.id);
const previewUrl = getAssetPreviewUrl(asset);
@@ -285,7 +357,7 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
<div style={{ padding: 10 }}>
<Text strong ellipsis style={{ display: 'block' }}>{asset.name || '未命名素材'}</Text>
<Space wrap size={4} style={{ marginTop: 6 }}>
<Tag color="green">Active</Tag>
{/* <Tag color="green">Active</Tag> */}
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
<Tag>{asset.projectName}</Tag>
</Space>