真人/虚拟人像库app build

This commit is contained in:
2026-07-07 15:18:52 +08:00
parent 6026ec8670
commit 98e8fe145f
15 changed files with 648 additions and 615 deletions
+53 -387
View File
@@ -1,17 +1,18 @@
import React, { useRef, useState, useEffect } from 'react';
import { Modal, Tooltip, Empty, Input, List, Spin, Tag, Typography, message } from 'antd';
import { HistoryOutlined, UserOutlined, FolderOpenOutlined, PlusOutlined, CheckOutlined, ReloadOutlined, SearchOutlined, PictureOutlined } from '@ant-design/icons';
import { getPrivatePortraitProjects, getPrivatePortraitSelectableAssets } from '../api';
import type { PrivatePortraitProject, PrivatePortraitSelectableAsset } from '../types';
const { Text } = Typography;
import React, { useRef, useState } from 'react';
import { Modal, Tooltip } from 'antd';
import { HistoryOutlined, UserOutlined, FolderOpenOutlined, PlusOutlined, TeamOutlined } from '@ant-design/icons';
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset } from '../types';
import PrivatePortraitAssetPicker from './privatePortrait/picker/AssetPicker';
interface UploadSelectorProps {
children: React.ReactNode;
accept?: string;
onLocalSelect?: (files: File[]) => void;
onHistorySelect?: (items: any[]) => void;
onPortraitSelect?: (items: any[]) => void;
/** 兼容旧页面:由 UploadSelector 内部打开素材选择器,确认后回传素材数组。 */
onPortraitSelect?: (items: PrivatePortraitSelectableAsset[]) => void;
/** 新页面推荐:只选择素材库类型,父组件自行打开统一选择器。 */
onPortraitLibrarySelect?: (libraryType: PrivatePortraitLibraryType) => void;
uploading?: boolean;
tooltipTitle?: string;
}
@@ -22,10 +23,15 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
onLocalSelect,
onHistorySelect,
onPortraitSelect,
onPortraitLibrarySelect,
uploading,
tooltipTitle,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [modalVisible, setModalVisible] = useState(false);
const [historyModalVisible, setHistoryModalVisible] = useState(false);
const [portraitPickerOpen, setPortraitPickerOpen] = useState(false);
const [portraitLibraryType, setPortraitLibraryType] = useState<PrivatePortraitLibraryType>('real_person');
const handleLocalSelect = () => {
fileInputRef.current?.click();
@@ -41,115 +47,25 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
}
};
const [modalVisible, setModalVisible] = useState(false);
const [historyModalVisible, setHistoryModalVisible] = useState(false);
const [portraitModalVisible, setPortraitModalVisible] = useState(false);
const handleClick = () => {
if (!uploading) {
setModalVisible(true);
}
};
const mockHistoryData = [];
const [selectedHistoryItems, setSelectedHistoryItems] = useState<number[]>([]);
const [selectedPortraitItems, setSelectedPortraitItems] = useState<Map<string, PrivatePortraitSelectableAsset>>(new Map());
const [historyActiveTab, setHistoryActiveTab] = useState<'asset' | 'history'>('asset');
const [portraitProjects, setPortraitProjects] = useState<PrivatePortraitProject[]>([]);
const [portraitProjectId, setPortraitProjectId] = useState<string | undefined>();
const [portraitKeyword, setPortraitKeyword] = useState('');
const [portraitAssets, setPortraitAssets] = useState<PrivatePortraitSelectableAsset[]>([]);
const [loadingPortraitProjects, setLoadingPortraitProjects] = useState(false);
const [loadingPortraitAssets, setLoadingPortraitAssets] = useState(false);
const getPreviewUrl = (url?: string | null) => {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:') || url.startsWith('blob:')) {
return url;
const openPortraitPicker = (libraryType: PrivatePortraitLibraryType) => {
setModalVisible(false);
if (onPortraitLibrarySelect) {
onPortraitLibrarySelect(libraryType);
return;
}
const base = (import.meta.env.VITE_API_BASE || 'http://localhost:8000').replace(/\/$/, '');
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
};
const loadPortraitProjects = async () => {
setLoadingPortraitProjects(true);
try {
const res = await getPrivatePortraitProjects({ page: 1, pageSize: 100, status: 'active' });
setPortraitProjects(res.items || []);
if (!portraitProjectId && res.items?.length) {
setPortraitProjectId(res.items[0].id);
}
} catch (err: any) {
message.error(err?.message || '加载真人素材项目失败');
} finally {
setLoadingPortraitProjects(false);
}
};
const loadPortraitAssets = async () => {
setLoadingPortraitAssets(true);
try {
const res = await getPrivatePortraitSelectableAssets({ projectId: portraitProjectId, keyword: portraitKeyword.trim() || undefined, page: 1, pageSize: 100 });
setPortraitAssets(res.items || []);
} catch (err: any) {
message.error(err?.message || '加载真人素材失败');
} finally {
setLoadingPortraitAssets(false);
}
};
useEffect(() => {
if (!portraitModalVisible) return;
setSelectedPortraitItems(new Map());
loadPortraitProjects();
}, [portraitModalVisible]);
useEffect(() => {
if (!portraitModalVisible) return;
loadPortraitAssets();
}, [portraitModalVisible, portraitProjectId]);
const togglePortraitAsset = (asset: PrivatePortraitSelectableAsset) => {
setSelectedPortraitItems((prev) => {
const next = new Map(prev);
if (next.has(asset.id)) {
next.delete(asset.id);
} else {
next.set(asset.id, asset);
}
return next;
});
};
const toggleHistoryItem = (id: number) => {
setSelectedHistoryItems(prev =>
prev.includes(id) ? prev.filter(item => item !== id) : [...prev, id]
);
setPortraitLibraryType(libraryType);
setPortraitPickerOpen(true);
};
const confirmHistorySelection = () => {
const items = mockHistoryData.filter(item => selectedHistoryItems.includes(item.id));
onHistorySelect?.(items);
onHistorySelect?.([]);
setHistoryModalVisible(false);
setSelectedHistoryItems([]);
setModalVisible(false);
};
const confirmPortraitSelection = () => {
const selected = Array.from(selectedPortraitItems.values());
if (!selected.length) {
message.warning('请选择至少一个真人素材');
return;
}
const transformedItems = selected.map(item => ({
...item,
avatar: getPreviewUrl(item.previewUrl),
}));
onPortraitSelect?.(transformedItems);
setPortraitModalVisible(false);
setSelectedPortraitItems(new Map());
setModalVisible(false);
};
@@ -165,14 +81,18 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
},
},
{
key: 'portrait',
label: '人像',
key: 'real_person',
label: '真人素材',
icon: <UserOutlined style={{ fontSize: 20, color: '#ec4899' }} />,
description: '从人像库中选择',
onClick: () => {
setModalVisible(false);
setPortraitModalVisible(true);
},
description: '从真人私域素材库中选择',
onClick: () => openPortraitPicker('real_person'),
},
{
key: 'aigc_virtual',
label: '虚拟素材',
icon: <TeamOutlined style={{ fontSize: 20, color: '#8b5cf6' }} />,
description: '从虚拟私域素材库中选择',
onClick: () => openPortraitPicker('aigc_virtual'),
},
{
key: 'local',
@@ -198,12 +118,12 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
/>
{tooltipTitle ? (
<Tooltip title={tooltipTitle}>
<div onClick={handleClick} style={{ cursor: 'pointer' }}>
<div onClick={handleClick} style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
{children}
</div>
</Tooltip>
) : (
<div onClick={handleClick} style={{ cursor: 'pointer' }}>
<div onClick={handleClick} style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
{children}
</div>
)}
@@ -275,149 +195,20 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
<Modal
title="选择资产素材"
open={historyModalVisible}
onCancel={() => {
setHistoryModalVisible(false);
setSelectedHistoryItems([]);
}}
onCancel={() => setHistoryModalVisible(false)}
footer={null}
width={"80%"}
height={"50%"}
width="80%"
centered
destroyOnHidden
>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<div
onClick={() => setHistoryActiveTab('asset')}
style={{
padding: '6px 16px',
borderRadius: 6,
cursor: 'pointer',
fontSize: 14,
fontWeight: historyActiveTab === 'asset' ? 600 : 500,
color: historyActiveTab === 'asset' ? '#fff' : '#64748b',
background: historyActiveTab === 'asset' ? '#6366f1' : '#f1f5f9',
transition: 'all 0.2s ease',
}}
>
</div>
<div
onClick={() => setHistoryActiveTab('history')}
style={{
padding: '6px 16px',
borderRadius: 6,
cursor: 'pointer',
fontSize: 14,
fontWeight: historyActiveTab === 'history' ? 600 : 500,
color: historyActiveTab === 'history' ? '#fff' : '#64748b',
background: historyActiveTab === 'history' ? '#6366f1' : '#f1f5f9',
transition: 'all 0.2s ease',
}}
>
</div>
</div>
<div style={{ minHeight: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', color: '#94a3b8', fontSize: 14 }}>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 16, paddingTop: 16, borderTop: '1px solid #e2e8f0' }}>
<div style={{ fontSize: 14, color: '#64748b' }}>
<span style={{ color: '#ef4444', fontWeight: 600 }}>{selectedHistoryItems.length}</span>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<button
onClick={() => {
setHistoryModalVisible(false);
setSelectedHistoryItems([]);
}}
style={{
padding: '8px 24px',
borderRadius: 8,
border: '1px solid #e2e8f0',
background: '#fff',
cursor: 'pointer',
fontSize: 14,
color: '#64748b',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#cbd5e1';
e.currentTarget.style.background = '#f8fafc';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#e2e8f0';
e.currentTarget.style.background = '#fff';
}}
>
</button>
<button
onClick={confirmHistorySelection}
style={{
padding: '8px 24px',
borderRadius: 8,
border: 'none',
background: '#ef4444',
cursor: 'pointer',
fontSize: 14,
color: '#fff',
fontWeight: 600,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#dc2626';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#ef4444';
}}
>
</button>
</div>
</div>
</Modal>
<Modal
title="选择真人素材库"
open={portraitModalVisible}
onCancel={() => {
setPortraitModalVisible(false);
setSelectedPortraitItems(new Map());
}}
width={920}
centered
footer={[
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16, paddingTop: 16, borderTop: '1px solid #e2e8f0' }}>
<button
key="cancel"
onClick={() => {
setPortraitModalVisible(false);
setSelectedPortraitItems(new Map());
}}
style={{
padding: '8px 24px',
borderRadius: 8,
border: '1px solid #e2e8f0',
background: '#fff',
cursor: 'pointer',
fontSize: 14,
color: '#64748b',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#cbd5e1';
e.currentTarget.style.background = '#f8fafc';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#e2e8f0';
e.currentTarget.style.background = '#fff';
}}
></button>,
<button
key="ok"
onClick={confirmPortraitSelection}
onClick={confirmHistorySelection}
style={{
padding: '8px 24px',
borderRadius: 8,
@@ -429,148 +220,23 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
fontWeight: 600,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#7c3aed';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#8b5cf6';
}}
>
{selectedPortraitItems.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' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12, alignItems: 'center' }}>
<Text strong></Text>
<button
onClick={loadPortraitProjects}
disabled={loadingPortraitProjects}
style={{
padding: '4px 8px',
borderRadius: 6,
border: '1px solid #e2e8f0',
background: '#fff',
cursor: 'pointer',
fontSize: 12,
color: '#64748b',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<ReloadOutlined style={{ fontSize: 12 }} />
</button>
</div>
<Spin spinning={loadingPortraitProjects}>
<List
dataSource={portraitProjects}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无项目组" /> }}
renderItem={(item) => (
<List.Item
onClick={() => setPortraitProjectId(item.id)}
style={{
cursor: 'pointer',
padding: '10px 12px',
borderRadius: 10,
marginBottom: 6,
border: portraitProjectId === item.id ? '1px solid #8b5cf6' : '1px solid transparent',
background: portraitProjectId === 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>
<div style={{ display: 'flex', gap: 12, marginBottom: 12 }}>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="搜索素材名称"
value={portraitKeyword}
onChange={(e) => setPortraitKeyword(e.target.value)}
onPressEnter={loadPortraitAssets}
style={{ flex: 1 }}
/>
<button
onClick={loadPortraitAssets}
disabled={loadingPortraitAssets}
style={{
padding: '8px 16px',
borderRadius: 8,
border: '1px solid #e2e8f0',
background: '#fff',
cursor: 'pointer',
fontSize: 14,
color: '#64748b',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<ReloadOutlined />
</button>
</div>
<Spin spinning={loadingPortraitAssets}>
{portraitAssets.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 }}>
{portraitAssets.map((asset) => {
const active = selectedPortraitItems.has(asset.id);
return (
<div
key={asset.id}
onClick={() => togglePortraitAsset(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>
<div style={{ display: 'flex', gap: 4, marginTop: 6 }}>
<Tag color="green">Active</Tag>
<Tag>{asset.projectName}</Tag>
</div>
</div>
</div>
);
})}
</div>
)}
</Spin>
</div>
</button>
</div>
</Modal>
<PrivatePortraitAssetPicker
open={portraitPickerOpen}
libraryType={portraitLibraryType}
onClose={() => setPortraitPickerOpen(false)}
onSelect={(assets) => {
onPortraitSelect?.(assets);
setPortraitPickerOpen(false);
}}
/>
</>
);
};
export default UploadSelector;
export default UploadSelector;