真人素材库本地完成

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
+67
View File
@@ -7,6 +7,8 @@ import * as mock from './mock';
import type {
User, CreditRecord, Project, GenerationRecord, OptimizeParams, GenerateParams, OptimizeResult,
Industry, IndustryConfig, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification,
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitProject, PrivatePortraitValidateSession,
PrivatePortraitAssetListOut, PrivatePortraitAsset, PrivatePortraitSelectableAssetListOut,
} from '../types';
const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true';
// ── Auth ──────────────────────────────────────────────────
@@ -738,3 +740,68 @@ export async function getHomeCaseButton(id: string,limit:number=5): Promise<any>
export async function deleteResourcesMaterial(params:any): Promise<any> {
return api.delete(`/generation-ai/history/batch`, params);
}
export async function getPrivatePortraitConfig(): Promise<PrivatePortraitConfig> {
return api.get<PrivatePortraitConfig>('/private-portrait/config');
}
// ── Private Portrait Library ──────────────────────────────
export async function getPrivatePortraitProjects(params: { page?: number; pageSize?: number; keyword?: string; status?: string } = {}): Promise<PrivatePortraitProjectListOut> {
const query = new URLSearchParams();
query.set('page', String(params.page || 1));
query.set('page_size', String(params.pageSize || 20));
if (params.keyword) query.set('keyword', params.keyword);
if (params.status) query.set('status', params.status);
return api.get<PrivatePortraitProjectListOut>(`/private-portrait/projects?${query.toString()}`);
}
export async function createPrivatePortraitProject(payload: { name: string; description?: string | null }): Promise<PrivatePortraitProject> {
return api.post<PrivatePortraitProject>('/private-portrait/projects', payload);
}
export async function updatePrivatePortraitProject(projectId: string, payload: { name?: string; description?: string | null; status?: string }): Promise<PrivatePortraitProject> {
return api.put<PrivatePortraitProject>(`/private-portrait/projects/${projectId}`, payload);
}
export async function deletePrivatePortraitProject(projectId: string): Promise<void> {
await api.delete(`/private-portrait/projects/${projectId}`);
}
export async function createPrivatePortraitValidateSession(projectId: string, callbackRedirectUrl?: string): Promise<PrivatePortraitValidateSession> {
return api.post<PrivatePortraitValidateSession>(`/private-portrait/projects/${projectId}/validate-sessions`, { callback_redirect_url: callbackRedirectUrl || null });
}
export async function getPrivatePortraitValidateSession(sessionId: string): Promise<PrivatePortraitValidateSession> {
return api.get<PrivatePortraitValidateSession>(`/private-portrait/validate-sessions/${sessionId}`);
}
export async function createPrivatePortraitAsset(projectId: string, payload: { url: string; assetType?: string; name?: string | null }): Promise<PrivatePortraitAsset> {
return api.post<PrivatePortraitAsset>(`/private-portrait/projects/${projectId}/assets`, { url: payload.url, asset_type: payload.assetType || 'Image', name: payload.name || null });
}
export async function getPrivatePortraitAssets(projectId: string, params: { page?: number; pageSize?: number; status?: string; keyword?: string } = {}): Promise<PrivatePortraitAssetListOut> {
const query = new URLSearchParams();
query.set('page', String(params.page || 1));
query.set('page_size', String(params.pageSize || 20));
if (params.status) query.set('status', params.status);
if (params.keyword) query.set('keyword', params.keyword);
return api.get<PrivatePortraitAssetListOut>(`/private-portrait/projects/${projectId}/assets?${query.toString()}`);
}
export async function syncPrivatePortraitAsset(assetId: string): Promise<PrivatePortraitAsset> {
return api.post<PrivatePortraitAsset>(`/private-portrait/assets/${assetId}/sync`);
}
export async function deletePrivatePortraitAsset(assetId: string): Promise<void> {
await api.delete(`/private-portrait/assets/${assetId}`);
}
export async function getPrivatePortraitSelectableAssets(params: { projectId?: string; keyword?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitSelectableAssetListOut> {
const query = new URLSearchParams();
query.set('page', String(params.page || 1));
query.set('page_size', String(params.pageSize || 20));
if (params.projectId) query.set('project_id', params.projectId);
if (params.keyword) query.set('keyword', params.keyword);
return api.get<PrivatePortraitSelectableAssetListOut>(`/private-portrait/selectable-assets?${query.toString()}`);
}
@@ -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;
+91 -5
View File
@@ -30,6 +30,8 @@ import {
} from '../api';
import { useAppStore } from '../store/useAppStore';
import { PrivatePortraitAssetPicker } from '../components/privatePortrait';
import type { PrivatePortraitSelectableAsset } from '../types';
import {
PlusOutlined,
@@ -71,6 +73,9 @@ interface MediaReference {
duration?: number;
role?: string;
label?: string;
source?: string;
private_asset_id?: string;
remote_asset_id?: string;
}
interface Message {
@@ -167,6 +172,7 @@ const AIChatPage: React.FC = () => {
const [lastFrame, setLastFrame] = useState<MediaReference | null>(null);
const [uploadTarget, setUploadTarget] = useState<'first' | 'last' | null>(null);
const [referenceModeDropdownVisible, setReferenceModeDropdownVisible] = useState(false);
const [privateAssetPickerOpen, setPrivateAssetPickerOpen] = useState(false);
const [mediaStackHovered, setMediaStackHovered] = useState(false);
const mediaStackCloseTimerRef = useRef<number | null>(null);
const openMediaStackTray = useCallback(() => {
@@ -1298,6 +1304,41 @@ const AIChatPage: React.FC = () => {
};
const handlePrivatePortraitAssetsSelected = (assets: PrivatePortraitSelectableAsset[]) => {
if (mediaType !== 'video') {
message.warning('真人素材库第一版仅支持视频创作参考');
return;
}
const imageCount = currentMedia.filter((m) => m.type === 'image').length;
const available = Math.max(0, maxImage - imageCount);
if (assets.length > available) {
message.warning(`当前引擎最多还能添加 ${available} 张图片参考`);
return;
}
const added: MediaReference[] = assets.map((asset) => ({
name: asset.name || '真人素材',
type: 'image',
url: asset.previewUrl || '',
source: 'private_portrait_asset',
private_asset_id: asset.id,
label: '',
}));
const newList = [...currentMedia, ...added];
const labels = generateMediaLabels(newList);
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
message.success(`已添加 ${assets.length} 个真人素材参考`);
};
const buildPreviewUrl = (url: string) => {
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 || '').replace(/\/$/, '');
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
};
const handleRemoveMedia = (index: number) => {
const newList = currentMedia.filter((_, i) => i !== index);
const labels = generateMediaLabels(newList);
@@ -2407,6 +2448,15 @@ const AIChatPage: React.FC = () => {
</Tooltip>
</Upload>
)}
{mediaType === 'video' && currentMedia.length === 0 && (
<Button
size="small"
onClick={() => setPrivateAssetPickerOpen(true)}
style={{ marginTop: 8, borderRadius: 8, color: '#8b5cf6', borderColor: '#ddd6fe', background: '#fff' }}
>
</Button>
)}
{/* 层叠附件展示 - 鼠标移入向右排列展开 */}
{currentMedia.length > 0 && (
@@ -2432,7 +2482,7 @@ const AIChatPage: React.FC = () => {
<div style={{ position: 'relative', width: 52, height: 60 }}>
{media.type === 'image' ? (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
src={buildPreviewUrl(media.url)}
alt={media.name}
onClick={() => {
setAttachmentPreviewUrl(media.url);
@@ -2444,7 +2494,7 @@ const AIChatPage: React.FC = () => {
/>
) : media.type === 'video' ? (
<video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
src={buildPreviewUrl(media.url)}
muted
onClick={() => {
setAttachmentPreviewUrl(media.url);
@@ -2462,7 +2512,7 @@ const AIChatPage: React.FC = () => {
}}
style={{ width: 52, height: 60, objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '1px solid rgba(255,255,255,0.98)', boxShadow: '0 4px 12px rgba(31,41,55,0.15)', background: 'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
{playingAudioUrl === (media.url.startsWith('http') ? media.url : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`) ? (
{playingAudioUrl === (buildPreviewUrl(media.url)) ? (
<PauseOutlined style={{ fontSize: 20, color: '#fff' }} />
) : (
<AudioOutlined style={{ fontSize: 20, color: '#fff' }} />
@@ -2553,6 +2603,34 @@ const AIChatPage: React.FC = () => {
</Tooltip>
</Upload>
)}
{mediaType === 'video' && currentMedia.length > 0 && (
<Tooltip title="选择真人素材库">
<div
onClick={(e) => { e.stopPropagation(); setPrivateAssetPickerOpen(true); }}
style={{
position: 'absolute',
right: -32,
bottom: 0,
width: 28,
height: 28,
borderRadius: 50,
background: '#fff',
border: '1px solid #ddd6fe',
color: '#8b5cf6',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: 12,
fontWeight: 800,
boxShadow: '0 2px 8px rgba(47, 52, 64, 0.08)',
zIndex: 100,
}}
>
</div>
</Tooltip>
)}
</div>
)}
</>
@@ -3906,20 +3984,28 @@ const AIChatPage: React.FC = () => {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
{attachmentPreviewType === 'image' ? (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}`}
src={buildPreviewUrl(attachmentPreviewUrl)}
alt="预览"
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain' }}
/>
) : (
<video
ref={attachmentPreviewVideoRef}
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}`}
src={buildPreviewUrl(attachmentPreviewUrl)}
controls
style={{ maxWidth: '100%', maxHeight: '400px' }}
/>
)}
</div>
</Modal>
<PrivatePortraitAssetPicker
open={privateAssetPickerOpen}
onClose={() => setPrivateAssetPickerOpen(false)}
onSelect={handlePrivatePortraitAssetsSelected}
selectedIds={currentMedia.map((m) => m.private_asset_id).filter(Boolean) as string[]}
maxCount={maxImage}
/>
</Layout>
);
};
+36 -1
View File
@@ -16,7 +16,9 @@ import {
PlayCircleOutlined,
DeleteOutlined,
LoadingOutlined,
UserOutlined,
} from '@ant-design/icons';
import { PrivatePortraitLibraryPanel } from '../components/privatePortrait';
import { gethistory, gethistoryItems, getOAuthList, asyncBatchUploadMaterial, updateFilename, getUploadHistory, getAllOAuthAccountList, getOpenTypeAll, getPreTestList, getDefaultPreTest, deleteHistory, deleteResourcesMaterial } from '../api';
const { Search } = Input;
@@ -48,7 +50,7 @@ const GeneratedRecord: React.FC = () => {
setIsPageLoaded(true);
});
const [filterType, setFilterType] = useState<'project' | 'creation' | 'hot_opening_replicate' | 'shot_replicate'>('project');
const [filterType, setFilterType] = useState<'project' | 'creation' | 'hot_opening_replicate' | 'shot_replicate' | 'private_portrait'>('project');
const [filterMedia, setFilterMedia] = useState<'video' | 'image'>('video');
const [recordlist, setRecordList] = useState<any[]>([]);
const [Pagebreak, setPagebreak] = useState<any>({
@@ -754,6 +756,11 @@ const GeneratedRecord: React.FC = () => {
});
};
const loadRecordList = () => {
if (filterType === 'private_portrait') {
setLoading(false);
setRecordList([]);
return;
}
setLoading(true);
let historySource = '';
if (filterType === 'project') {
@@ -1003,8 +1010,29 @@ const GeneratedRecord: React.FC = () => {
>
</Button>
<Button
type={filterType === 'private_portrait' ? 'primary' : 'default'}
onClick={() => {
setFilterType('private_portrait');
setIsSelectionMode(false);
setSelectedItems(new Set());
}}
style={{
borderRadius: 8,
background: filterType === 'private_portrait'
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: '#f8f9fc',
border: filterType === 'private_portrait' ? 'none' : '1px solid #e2e8f0',
color: filterType === 'private_portrait' ? '#fff' : '#64748b',
fontWeight: 600,
}}
icon={<UserOutlined />}
>
</Button>
</Space>
</div>
{filterType !== 'private_portrait' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
{/* 多选模式按钮 */}
{isSelectionMode ? (
@@ -1085,7 +1113,12 @@ const GeneratedRecord: React.FC = () => {
)}
</div>
)}
</div>
{filterType === 'private_portrait' ? (
<PrivatePortraitLibraryPanel />
) : (
<>
{/* Second row filter: 视频 / 图片 */}
<div style={{
display: 'flex',
@@ -1501,6 +1534,8 @@ const GeneratedRecord: React.FC = () => {
)}
</div>
)}
</>
)}
{/* 推送任务历史弹窗 */}
<Modal
title="推送任务历史"
+94
View File
@@ -188,3 +188,97 @@ export interface AdminNotification {
isRead: boolean;
createdAt: string;
}
export interface PrivatePortraitConfig {
enabled: boolean;
imageLimit: number;
usedImageCount: number;
remainingImageCount: number;
}
export interface PrivatePortraitProject {
id: string;
userId?: string | null;
name: string;
nameSlug?: string | null;
remoteProjectName?: string | null;
description?: string | null;
status: string;
assetGroupCount: number;
assetCount: number;
activeAssetCount: number;
lastUsedAt?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface PrivatePortraitProjectListOut {
items: PrivatePortraitProject[];
total: number;
page: number;
pageSize: number;
}
export interface PrivatePortraitValidateSession {
id: string;
projectId: string;
bytedToken?: string | null;
h5Link?: string | null;
callbackUrl?: string | null;
resultCode?: string | null;
status: string;
remoteGroupId?: string | null;
remoteProjectName?: string | null;
errorMessage?: string | null;
expiredAt?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface PrivatePortraitAsset {
id: string;
userId?: string | null;
projectId: string;
projectName?: string | null;
groupId: string;
remoteGroupId: string;
remoteAssetId?: string | null;
remoteProjectName?: string | null;
assetType: string;
name?: string | null;
sourceUrl: string;
previewUrl?: string | null;
remoteUrl?: string | null;
status: string;
pollCount: number;
remoteDeleteStatus: string;
errorMessage?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface PrivatePortraitAssetListOut {
items: PrivatePortraitAsset[];
total: number;
page: number;
pageSize: number;
}
export interface PrivatePortraitSelectableAsset {
id: string;
projectId: string;
projectName: string;
name?: string | null;
assetType: string;
previewUrl?: string | null;
status: string;
createdAt?: string | null;
}
export interface PrivatePortraitSelectableAssetListOut {
items: PrivatePortraitSelectableAsset[];
total: number;
page: number;
pageSize: number;
}