真人素材库本地完成
This commit is contained in:
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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="推送任务历史"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user