真人素材库本地完成
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user