真人素材库本地完成

This commit is contained in:
2026-07-06 15:13:41 +08:00
parent 75d1f7a428
commit ab67bdf19e
52 changed files with 4131 additions and 137 deletions
@@ -0,0 +1,2 @@
export { default as PrivatePortraitLibraryPanel } from './library/LibraryPanel';
export { default as PrivatePortraitAssetPicker } from './picker/AssetPicker';
@@ -0,0 +1,59 @@
import React from 'react';
import { Button, Empty, Popconfirm, Space, Tag, Tooltip } from 'antd';
import { DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
import type { PrivatePortraitAsset } from '../../../types';
const statusColor: Record<string, string> = {
Active: 'green',
Processing: 'processing',
Failed: 'red',
local_deleted: 'default',
remote_deleted: 'default',
delete_failed: 'red',
};
interface Props {
items: PrivatePortraitAsset[];
loading?: boolean;
onSync: (assetId: string) => void;
onDelete: (assetId: string) => void;
}
const buildPreviewUrl = (url?: string | null) => {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url;
const base = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
return `${base}${url}`;
};
const PrivatePortraitAssetGrid: React.FC<Props> = ({ items, onSync, onDelete }) => {
if (!items.length) return <Empty description="暂无真人素材" />;
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 14 }}>
{items.map((item) => (
<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' }}>
{item.previewUrl || item.remoteUrl ? (
<img src={buildPreviewUrl(item.previewUrl || item.remoteUrl)} alt={item.name || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : <span style={{ color: '#94a3b8' }}></span>}
</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>
<div style={{ marginTop: 8 }}><Tag color={statusColor[item.status] || 'default'}>{item.status}</Tag></div>
{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>
);
};
export default PrivatePortraitAssetGrid;
@@ -0,0 +1,61 @@
import React, { useState } from 'react';
import { Button, Input, Modal, Upload, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import { createPrivatePortraitAsset, uploadImage } from '../../../api';
interface Props {
projectId: string;
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
const PrivatePortraitAssetUpload: React.FC<Props> = ({ projectId, open, onClose, onSuccess }) => {
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
const file = fileList[0]?.originFileObj as File | undefined;
if (!file) {
message.warning('请先选择图片素材');
return;
}
setLoading(true);
try {
const uploaded = await uploadImage(file);
await createPrivatePortraitAsset(projectId, { url: uploaded.url, assetType: 'Image', name: name || file.name });
message.success('素材已提交入库,处理中');
setFileList([]);
setName('');
onSuccess();
onClose();
} catch (e: any) {
message.error(e?.message || '上传素材失败');
} finally {
setLoading(false);
}
};
return (
<Modal title="上传真人素材" open={open} onCancel={onClose} onOk={handleSubmit} confirmLoading={loading} okText="提交入库">
<div style={{ display: 'grid', gap: 12 }}>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="素材名称,默认使用文件名" />
<Upload
accept="image/*"
maxCount={1}
fileList={fileList}
beforeUpload={() => false}
onChange={({ fileList }) => setFileList(fileList)}
listType="picture"
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<div style={{ color: '#64748b', fontSize: 12 }}>Active AI </div>
</div>
</Modal>
);
};
export default PrivatePortraitAssetUpload;
@@ -0,0 +1,85 @@
import React, { useEffect, useState } from 'react';
import { Button, Card, Col, Form, Input, Modal, Row, Space, Typography, message } from 'antd';
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import type { PrivatePortraitProject } from '../../../types';
import { createPrivatePortraitProject, getPrivatePortraitProjects } from '../../../api';
import PrivatePortraitProjectList from './ProjectList';
import PrivatePortraitProjectDetail from './ProjectDetail';
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 [form] = Form.useForm();
const loadProjects = async () => {
setLoading(true);
try {
const res = await getPrivatePortraitProjects({ pageSize: 100 });
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(); }, []);
const handleCreate = async () => {
const values = await form.validateFields();
try {
const project = await createPrivatePortraitProject(values);
message.success('项目组已创建');
setCreateOpen(false);
form.resetFields();
await loadProjects();
setSelected(project);
} catch (e: any) {
message.error(e?.message || '创建项目组失败');
}
};
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"> Active AI </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={() => setCreateOpen(false)} onOk={handleCreate} okText="创建">
<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>
</Form>
</Modal>
</div>
);
};
export default PrivatePortraitLibraryPanel;
@@ -0,0 +1,91 @@
import React, { useEffect, useState } from 'react';
import { Button, Card, Popconfirm, Space, Typography, message } from 'antd';
import { DeleteOutlined, ReloadOutlined, SafetyCertificateOutlined, UploadOutlined } from '@ant-design/icons';
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types';
import { deletePrivatePortraitAsset, deletePrivatePortraitProject, getPrivatePortraitAssets, syncPrivatePortraitAsset } from '../../../api';
import PrivatePortraitAssetGrid from './AssetGrid';
import PrivatePortraitAssetUpload from './AssetUpload';
import PrivatePortraitValidateModal from './ValidateModal';
interface Props {
project: PrivatePortraitProject;
onDeleted: () => void;
onChanged: () => void;
}
const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onChanged }) => {
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
const [loading, setLoading] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
const [validateOpen, setValidateOpen] = useState(false);
const loadAssets = async () => {
setLoading(true);
try {
const res = await getPrivatePortraitAssets(project.id, { pageSize: 100 });
setAssets(res.items);
} catch (e: any) {
message.error(e?.message || '加载素材失败');
} finally {
setLoading(false);
}
};
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 || '刷新失败');
}
};
const handleDeleteAsset = async (assetId: string) => {
try {
await deletePrivatePortraitAsset(assetId);
await loadAssets();
onChanged();
message.success('素材已删除');
} catch (e: any) {
message.error(e?.message || '删除素材失败');
}
};
const handleDeleteProject = async () => {
try {
await deletePrivatePortraitProject(project.id);
message.success('项目组已删除');
onDeleted();
} catch (e: any) {
message.error(e?.message || '删除项目组失败');
}
};
return (
<Card
title={<span>{project.name}</span>}
extra={(
<Space>
<Button icon={<SafetyCertificateOutlined />} onClick={() => setValidateOpen(true)}></Button>
<Button type="primary" icon={<UploadOutlined />} 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 }}
>
<Typography.Paragraph style={{ color: '#64748b' }}>{project.description || '暂无描述'}</Typography.Paragraph>
<PrivatePortraitAssetGrid items={assets} loading={loading} onSync={handleSync} onDelete={handleDeleteAsset} />
<PrivatePortraitAssetUpload projectId={project.id} open={uploadOpen} onClose={() => setUploadOpen(false)} onSuccess={() => { loadAssets(); onChanged(); }} />
<PrivatePortraitValidateModal projectId={project.id} open={validateOpen} onClose={() => setValidateOpen(false)} onCreated={() => onChanged()} />
</Card>
);
};
export default PrivatePortraitProjectDetail;
@@ -0,0 +1,35 @@
import React from 'react';
import { Button, Empty, List, Tag } from 'antd';
import type { PrivatePortraitProject } from '../../../types';
interface Props {
items: PrivatePortraitProject[];
selectedId?: string | null;
onSelect: (project: PrivatePortraitProject) => void;
}
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' }}
>
<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>
)}
/>
);
};
export default PrivatePortraitProjectList;
@@ -0,0 +1,49 @@
import React, { useState } from 'react';
import { Button, Modal, Space, Typography, message } from 'antd';
import { createPrivatePortraitValidateSession } from '../../../api';
import type { PrivatePortraitValidateSession } from '../../../types';
interface Props {
projectId: string;
open: boolean;
onClose: () => void;
onCreated: (session: PrivatePortraitValidateSession) => void;
}
const PrivatePortraitValidateModal: React.FC<Props> = ({ projectId, open, onClose, onCreated }) => {
const [loading, setLoading] = useState(false);
const [session, setSession] = useState<PrivatePortraitValidateSession | null>(null);
const handleCreate = async () => {
setLoading(true);
try {
const redirect = `${window.location.origin}${window.location.pathname}#/private-portrait-authorized`;
const next = await createPrivatePortraitValidateSession(projectId, redirect);
setSession(next);
onCreated(next);
if (next.h5Link) window.open(next.h5Link, '_blank');
} catch (e: any) {
message.error(e?.message || '创建真人授权链接失败');
} finally {
setLoading(false);
}
};
return (
<Modal title="真人授权认证" open={open} onCancel={onClose} footer={null} width={640}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Typography.Paragraph style={{ color: '#475569' }}>
H5 Asset Group
</Typography.Paragraph>
<Button type="primary" loading={loading} onClick={handleCreate}></Button>
{session?.h5Link && (
<div style={{ padding: 12, border: '1px solid #e2e8f0', borderRadius: 8, wordBreak: 'break-all' }}>
<Typography.Text copyable>{session.h5Link}</Typography.Text>
</div>
)}
</Space>
</Modal>
);
};
export default PrivatePortraitValidateModal;
@@ -0,0 +1,222 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Button, Empty, Input, List, Modal, Space, Spin, Tag, Typography, message } from 'antd';
import { CheckOutlined, PictureOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { getPrivatePortraitProjects, getPrivatePortraitSelectableAssets } from '../../../api';
import type { PrivatePortraitProject, PrivatePortraitSelectableAsset } from '../../../types';
const { Text } = Typography;
interface PrivatePortraitAssetPickerProps {
open: boolean;
selectedIds?: string[];
maxCount?: number;
onClose: () => void;
onSelect: (assets: PrivatePortraitSelectableAsset[]) => void;
}
const getPreviewUrl = (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 PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
open,
selectedIds = [],
maxCount = 20,
onClose,
onSelect,
}) => {
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [projectId, setProjectId] = useState<string | undefined>();
const [keyword, setKeyword] = useState('');
const [assets, setAssets] = useState<PrivatePortraitSelectableAsset[]>([]);
const [checked, setChecked] = useState<Set<string>>(new Set(selectedIds));
const [loadingProjects, setLoadingProjects] = useState(false);
const [loadingAssets, setLoadingAssets] = useState(false);
const selectedMap = useMemo(() => {
const map = new Map<string, PrivatePortraitSelectableAsset>();
assets.forEach((item) => {
if (checked.has(item.id)) map.set(item.id, item);
});
return map;
}, [assets, checked]);
const loadProjects = async () => {
setLoadingProjects(true);
try {
const res = await getPrivatePortraitProjects({ page: 1, pageSize: 100, status: 'active' });
setProjects(res.items || []);
if (!projectId && res.items?.length) {
setProjectId(res.items[0].id);
}
} catch (err: any) {
message.error(err?.message || '加载真人素材项目失败');
} finally {
setLoadingProjects(false);
}
};
const loadAssets = async () => {
setLoadingAssets(true);
try {
const res = await getPrivatePortraitSelectableAssets({ projectId, keyword: keyword.trim() || undefined, page: 1, pageSize: 100 });
setAssets(res.items || []);
} catch (err: any) {
message.error(err?.message || '加载真人素材失败');
} finally {
setLoadingAssets(false);
}
};
useEffect(() => {
if (!open) return;
setChecked(new Set(selectedIds));
loadProjects();
}, [open]);
useEffect(() => {
if (!open) return;
loadAssets();
}, [open, projectId]);
const toggle = (asset: PrivatePortraitSelectableAsset) => {
setChecked((prev) => {
const next = new Set(prev);
if (next.has(asset.id)) {
next.delete(asset.id);
return next;
}
if (next.size >= maxCount) {
message.warning(`最多选择 ${maxCount} 个参考素材`);
return prev;
}
next.add(asset.id);
return next;
});
};
const confirm = () => {
const selected = assets.filter((item) => checked.has(item.id));
if (!selected.length) {
message.warning('请选择至少一个真人素材');
return;
}
onSelect(selected);
onClose();
};
return (
<Modal
title="选择真人素材库"
open={open}
onCancel={onClose}
width={920}
footer={[
<Button key="cancel" onClick={onClose}></Button>,
<Button key="ok" type="primary" onClick={confirm} style={{ background: '#8b5cf6' }}>
{checked.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' }}>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 12 }}>
<Text strong></Text>
<Button size="small" icon={<ReloadOutlined />} onClick={loadProjects} loading={loadingProjects} />
</Space>
<Spin spinning={loadingProjects}>
<List
dataSource={projects}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无项目组" /> }}
renderItem={(item) => (
<List.Item
onClick={() => setProjectId(item.id)}
style={{
cursor: 'pointer',
padding: '10px 12px',
borderRadius: 10,
marginBottom: 6,
border: projectId === item.id ? '1px solid #8b5cf6' : '1px solid transparent',
background: projectId === item.id ? '#f5f3ff' : '#fff',
}}
>
<div style={{ width: '100%' }}>
<Text strong ellipsis style={{ display: 'block' }}>{item.name}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>Active {item.activeAssetCount || 0}</Text>
</div>
</List.Item>
)}
/>
</Spin>
</div>
<div>
<Space style={{ width: '100%', marginBottom: 12 }}>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="搜索素材名称"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={loadAssets}
/>
<Button icon={<ReloadOutlined />} onClick={loadAssets} loading={loadingAssets}></Button>
</Space>
<Spin spinning={loadingAssets}>
{assets.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无可选 Active 真人素材" 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) => {
const active = checked.has(asset.id);
return (
<div
key={asset.id}
onClick={() => toggle(asset)}
style={{
cursor: 'pointer',
border: active ? '2px solid #8b5cf6' : '1px solid #edf0f5',
borderRadius: 12,
overflow: 'hidden',
background: '#fff',
boxShadow: active ? '0 8px 20px rgba(139,92,246,0.18)' : '0 4px 12px rgba(15,23,42,0.04)',
position: 'relative',
}}
>
<div style={{ aspectRatio: '1 / 1', background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{asset.previewUrl ? (
<img src={getPreviewUrl(asset.previewUrl)} alt={asset.name || '真人素材'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<PictureOutlined style={{ fontSize: 32, color: '#94a3b8' }} />
)}
</div>
{active && (
<div style={{ position: 'absolute', top: 8, right: 8, width: 24, height: 24, borderRadius: 12, background: '#8b5cf6', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CheckOutlined />
</div>
)}
<div style={{ padding: 10 }}>
<Text strong ellipsis style={{ display: 'block' }}>{asset.name || '未命名素材'}</Text>
<Space style={{ marginTop: 6 }}>
<Tag color="green">Active</Tag>
<Tag>{asset.projectName}</Tag>
</Space>
</div>
</div>
);
})}
</div>
)}
</Spin>
</div>
</div>
</Modal>
);
};
export default PrivatePortraitAssetPicker;