1
This commit is contained in:
Vendored
+110
-110
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-BJj5fRkV.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-W_zV5GBi.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -448,7 +448,7 @@ export async function deleteMenuConfig(id: string): Promise<void> {
|
||||
|
||||
// ── User Creation ───────────────────────────────────────
|
||||
|
||||
export async function createUser(data: { username?: string; password: string; email?: string; phone?: string; credits: number; user_type: string; frontend_user_kind?: string; allowed_menus?: string[] | null; private_portrait_image_limit?: number }): Promise<any> {
|
||||
export async function createUser(data: { username?: string; password: string; email?: string; phone?: string; credits: number; user_type: string; frontend_user_kind?: string; allowed_menus?: string[] | null; private_portrait_asset_limit?: number }): Promise<any> {
|
||||
return api.post('/admin/users', data);
|
||||
}
|
||||
|
||||
@@ -1017,22 +1017,25 @@ export async function getPreTestTemplateList(params: PreTestTemplateListParams):
|
||||
}
|
||||
|
||||
// ── Private Portrait Admin ────────────────────────────────
|
||||
export async function adminGetPrivatePortraitProjects(params: { userId?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitProjectListOut> {
|
||||
export async function adminGetPrivatePortraitProjects(params: { userId?: string; libraryType?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitProjectListOut> {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', String(params.page || 1));
|
||||
query.set('page_size', String(params.pageSize || 20));
|
||||
if (params.userId) query.set('user_id', params.userId);
|
||||
if (params.libraryType) query.set('library_type', params.libraryType);
|
||||
if (params.keyword) query.set('keyword', params.keyword);
|
||||
if (params.status) query.set('status', params.status);
|
||||
return api.get<PrivatePortraitProjectListOut>(`/admin/private-portrait/projects?${query.toString()}`);
|
||||
}
|
||||
|
||||
export async function adminGetPrivatePortraitAssets(params: { userId?: string; projectId?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitAssetListOut> {
|
||||
export async function adminGetPrivatePortraitAssets(params: { userId?: string; projectId?: string; libraryType?: string; assetType?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitAssetListOut> {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', String(params.page || 1));
|
||||
query.set('page_size', String(params.pageSize || 20));
|
||||
if (params.userId) query.set('user_id', params.userId);
|
||||
if (params.projectId) query.set('project_id', params.projectId);
|
||||
if (params.libraryType) query.set('library_type', params.libraryType);
|
||||
if (params.assetType) query.set('asset_type', params.assetType);
|
||||
if (params.keyword) query.set('keyword', params.keyword);
|
||||
if (params.status) query.set('status', params.status);
|
||||
return api.get<PrivatePortraitAssetListOut>(`/admin/private-portrait/assets?${query.toString()}`);
|
||||
@@ -1044,5 +1047,5 @@ export async function adminGetPrivatePortraitConfig(userId: string): Promise<Pri
|
||||
}
|
||||
|
||||
export async function adminUpdatePrivatePortraitConfig(userId: string, limit: number): Promise<PrivatePortraitConfig> {
|
||||
return api.put<PrivatePortraitConfig>(`/admin/private-portrait/users/${userId}/config`, { private_portrait_image_limit: limit });
|
||||
return api.put<PrivatePortraitConfig>(`/admin/private-portrait/users/${userId}/config`, { private_portrait_asset_limit: limit });
|
||||
}
|
||||
|
||||
@@ -1,20 +1,110 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Card, Input, Space, Table, Tag, Typography, message } from 'antd';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Card, Image, Input, Select, Space, Statistic, Table, Tag, Tooltip, Typography, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { CopyOutlined, PlayCircleOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { adminGetPrivatePortraitAssets, adminGetPrivatePortraitProjects } from '../api';
|
||||
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
import { apiUrl } from '../utils/resourceUrl';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const LIBRARY_OPTIONS = [
|
||||
{ label: '全部素材库', value: '' },
|
||||
{ label: '真人认证', value: 'real_person' },
|
||||
{ label: '虚拟人像', value: 'aigc_virtual' },
|
||||
];
|
||||
|
||||
const ASSET_TYPE_OPTIONS = [
|
||||
{ label: '全部类型', value: '' },
|
||||
{ label: '图片', value: 'Image' },
|
||||
{ label: '视频', value: 'Video' },
|
||||
];
|
||||
|
||||
const PROJECT_STATUS_OPTIONS = [
|
||||
{ label: '全部项目状态', value: '' },
|
||||
{ label: '可用', value: 'active' },
|
||||
{ label: '认证中', value: 'validating' },
|
||||
{ label: '认证失败', value: 'validate_failed' },
|
||||
{ label: '创建远端组中', value: 'creating_remote_group' },
|
||||
{ label: '创建远端组失败', value: 'create_group_failed' },
|
||||
{ label: '已删除', value: 'deleted' },
|
||||
];
|
||||
|
||||
const ASSET_STATUS_OPTIONS = [
|
||||
{ label: '全部素材状态', value: '' },
|
||||
{ label: '创建中', value: 'creating' },
|
||||
{ label: '处理中', value: 'Processing' },
|
||||
{ label: '可用', value: 'Active' },
|
||||
{ label: '失败', value: 'Failed' },
|
||||
{ label: '本地已删', value: 'local_deleted' },
|
||||
{ label: '远端已删', value: 'remote_deleted' },
|
||||
{ label: '删除失败', value: 'delete_failed' },
|
||||
];
|
||||
|
||||
const libraryLabel = (value?: string | null) => {
|
||||
if (value === 'real_person') return '真人认证';
|
||||
if (value === 'aigc_virtual') return '虚拟人像';
|
||||
return value || '-';
|
||||
};
|
||||
|
||||
const libraryColor = (value?: string | null) => {
|
||||
if (value === 'real_person') return 'purple';
|
||||
if (value === 'aigc_virtual') return 'cyan';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const statusColor = (status?: string) => {
|
||||
if (status === 'Active' || status === 'active') return 'green';
|
||||
if (status === 'Processing') return 'blue';
|
||||
if (status === 'Failed' || status === 'failed' || status === 'delete_failed') return 'red';
|
||||
if (status === 'Processing' || status === 'creating_remote_group' || status === 'validating' || status === 'creating') return 'blue';
|
||||
if (status === 'Failed' || status === 'failed' || status === 'delete_failed' || status === 'validate_failed' || status === 'create_group_failed') return 'red';
|
||||
if (status?.includes('deleted')) return 'default';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const assetTypeLabel = (value?: string | null) => {
|
||||
if (value === 'Image') return '图片';
|
||||
if (value === 'Video') return '视频';
|
||||
return value || '-';
|
||||
};
|
||||
|
||||
const shortId = (value?: string | null, keep = 10) => {
|
||||
if (!value) return '-';
|
||||
if (value.length <= keep * 2 + 3) return value;
|
||||
return `${value.slice(0, keep)}...${value.slice(-keep)}`;
|
||||
};
|
||||
|
||||
const copyText = async (value?: string | null) => {
|
||||
if (!value) return;
|
||||
await navigator.clipboard?.writeText(value);
|
||||
message.success('已复制');
|
||||
};
|
||||
|
||||
const toPreviewUrl = (value?: string | null) => {
|
||||
if (!value) return '';
|
||||
const trimmed = String(value).trim();
|
||||
if (!trimmed || trimmed.startsWith('asset://')) return '';
|
||||
return apiUrl(trimmed);
|
||||
};
|
||||
|
||||
const PreviewCell: React.FC<{ asset: PrivatePortraitAsset }> = ({ asset }) => {
|
||||
const url = toPreviewUrl(asset.displayUrl || asset.previewUrl || asset.remoteUrl || asset.sourceUrl);
|
||||
const cover = toPreviewUrl(asset.videoCoverUrl || asset.previewUrl || asset.displayUrl || asset.sourceUrl);
|
||||
if (asset.assetType === 'Video') {
|
||||
return (
|
||||
<div style={{ width: 72, height: 52, borderRadius: 10, overflow: 'hidden', background: '#f5f3ff', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
|
||||
{cover ? <img src={cover} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : <PlayCircleOutlined style={{ fontSize: 24, color: '#8b5cf6' }} />}
|
||||
{url ? <a href={url} target="_blank" rel="noreferrer" style={{ position: 'absolute', inset: 0 }} /> : null}
|
||||
<PlayCircleOutlined style={{ position: 'absolute', color: '#fff', fontSize: 22, filter: 'drop-shadow(0 1px 3px rgba(0,0,0,.45))' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (url) return <Image width={72} height={52} src={url} style={{ objectFit: 'cover', borderRadius: 10 }} />;
|
||||
return <div style={{ width: 72, height: 52, borderRadius: 10, background: '#f5f5f5' }} />;
|
||||
};
|
||||
|
||||
const AdminPrivatePortraitProjects: React.FC = () => {
|
||||
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
||||
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
|
||||
@@ -23,18 +113,36 @@ const AdminPrivatePortraitProjects: React.FC = () => {
|
||||
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||
const [loadingAssets, setLoadingAssets] = useState(false);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [libraryType, setLibraryType] = useState('');
|
||||
const [assetType, setAssetType] = useState('');
|
||||
const [projectStatus, setProjectStatus] = useState('');
|
||||
const [assetStatus, setAssetStatus] = useState('');
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | undefined>();
|
||||
const [projectPage, setProjectPage] = useState(1);
|
||||
const [assetPage, setAssetPage] = useState(1);
|
||||
|
||||
const filters = useMemo(() => ({
|
||||
keyword: keyword.trim() || undefined,
|
||||
libraryType: libraryType || undefined,
|
||||
assetType: assetType || undefined,
|
||||
projectStatus: projectStatus || undefined,
|
||||
assetStatus: assetStatus || undefined,
|
||||
}), [keyword, libraryType, assetType, projectStatus, assetStatus]);
|
||||
|
||||
const loadProjects = async () => {
|
||||
setLoadingProjects(true);
|
||||
try {
|
||||
const res = await adminGetPrivatePortraitProjects({ keyword: keyword.trim() || undefined, page: projectPage, pageSize: 20 });
|
||||
const res = await adminGetPrivatePortraitProjects({
|
||||
keyword: filters.keyword,
|
||||
libraryType: filters.libraryType,
|
||||
status: filters.projectStatus,
|
||||
page: projectPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
setProjects(res.items || []);
|
||||
setProjectTotal(res.total || 0);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载真人素材项目失败');
|
||||
message.error(err?.message || '加载私域人像素材项目失败');
|
||||
} finally {
|
||||
setLoadingProjects(false);
|
||||
}
|
||||
@@ -43,88 +151,111 @@ const AdminPrivatePortraitProjects: React.FC = () => {
|
||||
const loadAssets = async () => {
|
||||
setLoadingAssets(true);
|
||||
try {
|
||||
const res = await adminGetPrivatePortraitAssets({ projectId: selectedProjectId, keyword: keyword.trim() || undefined, page: assetPage, pageSize: 20 });
|
||||
const res = await adminGetPrivatePortraitAssets({
|
||||
projectId: selectedProjectId,
|
||||
keyword: filters.keyword,
|
||||
libraryType: filters.libraryType,
|
||||
assetType: filters.assetType,
|
||||
status: filters.assetStatus,
|
||||
page: assetPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
setAssets(res.items || []);
|
||||
setAssetTotal(res.total || 0);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载真人素材失败');
|
||||
message.error(err?.message || '加载私域人像素材失败');
|
||||
} finally {
|
||||
setLoadingAssets(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadProjects(); }, [projectPage]);
|
||||
useEffect(() => { loadAssets(); }, [selectedProjectId, assetPage]);
|
||||
useEffect(() => { loadProjects(); }, [projectPage, filters.libraryType, filters.projectStatus]);
|
||||
useEffect(() => { loadAssets(); }, [selectedProjectId, assetPage, filters.libraryType, filters.assetType, filters.assetStatus]);
|
||||
|
||||
const searchAll = () => {
|
||||
setProjectPage(1);
|
||||
setAssetPage(1);
|
||||
setTimeout(() => { loadProjects(); loadAssets(); }, 0);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setKeyword('');
|
||||
setLibraryType('');
|
||||
setAssetType('');
|
||||
setProjectStatus('');
|
||||
setAssetStatus('');
|
||||
setSelectedProjectId(undefined);
|
||||
setProjectPage(1);
|
||||
setAssetPage(1);
|
||||
};
|
||||
|
||||
const projectSummary = useMemo(() => {
|
||||
return projects.reduce((acc, item) => {
|
||||
acc.asset += item.assetCount || 0;
|
||||
acc.image += item.imageAssetCount || 0;
|
||||
acc.video += item.videoAssetCount || 0;
|
||||
acc.active += item.activeAssetCount || 0;
|
||||
return acc;
|
||||
}, { asset: 0, image: 0, video: 0, active: 0 });
|
||||
}, [projects]);
|
||||
|
||||
const projectColumns: ColumnsType<PrivatePortraitProject> = [
|
||||
{ title: '项目名称', dataIndex: 'name', width: 180, render: (v, r) => <Button type="link" onClick={() => { setSelectedProjectId(r.id); setAssetPage(1); }}>{v}</Button> },
|
||||
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code>{v || '-'}</Text> },
|
||||
{ title: '状态', dataIndex: 'status', width: 100, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
|
||||
{ title: 'ProjectName', dataIndex: 'remoteProjectName', width: 240, render: (v) => <Text code copyable>{v || '-'}</Text> },
|
||||
{ title: '素材数', dataIndex: 'assetCount', width: 100 },
|
||||
{ title: 'Active', dataIndex: 'activeAssetCount', width: 100 },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v) => v || '-' },
|
||||
{ title: '项目名称', dataIndex: 'name', width: 200, fixed: 'left', render: (v, r) => <Button type="link" onClick={() => { setSelectedProjectId(r.id); setAssetPage(1); }}>{v}</Button> },
|
||||
{ title: '类型', dataIndex: 'libraryType', width: 110, render: (v) => <Tag color={libraryColor(v)}>{libraryLabel(v)}</Tag> },
|
||||
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
|
||||
{ title: '状态', dataIndex: 'status', width: 130, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
|
||||
{ title: '素材', width: 170, render: (_, r) => <Space size={4}><Tag>总 {r.assetCount || 0}</Tag><Tag color="blue">图 {r.imageAssetCount || 0}</Tag><Tag color="geekblue">视 {r.videoAssetCount || 0}</Tag></Space> },
|
||||
{ title: 'Active', dataIndex: 'activeAssetCount', width: 90 },
|
||||
{ title: 'ProjectName', dataIndex: 'remoteProjectName', width: 180, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: formatDate },
|
||||
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: formatDate },
|
||||
];
|
||||
|
||||
const assetColumns: ColumnsType<PrivatePortraitAsset> = [
|
||||
{ title: '素材', dataIndex: 'name', width: 180, render: (v, r) => v || r.remoteAssetId || '-' },
|
||||
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code>{v || '-'}</Text> },
|
||||
{ title: '本地项目', dataIndex: 'projectName', width: 160 },
|
||||
{ title: 'AssetId', dataIndex: 'remoteAssetId', width: 240, render: (v) => <Text code copyable>{v}</Text> },
|
||||
{ title: 'GroupId', dataIndex: 'remoteGroupId', width: 240, render: (v) => <Text code copyable>{v}</Text> },
|
||||
{ title: 'ProjectName', dataIndex: 'remoteProjectName', width: 240, render: (v) => <Text code copyable>{v || '-'}</Text> },
|
||||
{ title: '类型', dataIndex: 'assetType', width: 90 },
|
||||
{ title: '状态', dataIndex: 'status', width: 110, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
|
||||
{ title: '错误', dataIndex: 'errorMessage', width: 240, ellipsis: true, render: (v) => v || '-' },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v) => v || '-' },
|
||||
{ title: '预览', width: 100, fixed: 'left', render: (_, r) => <PreviewCell asset={r} /> },
|
||||
{ title: '素材名称', dataIndex: 'name', width: 180, render: (v, r) => <div><Text strong>{v || '未命名素材'}</Text><br /><Text type="secondary">{assetTypeLabel(r.assetType)}</Text></div> },
|
||||
{ title: '库类型', dataIndex: 'libraryType', width: 110, render: (v) => <Tag color={libraryColor(v)}>{libraryLabel(v)}</Tag> },
|
||||
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
|
||||
{ title: '本地项目', dataIndex: 'projectName', width: 160, render: (v) => v || '-' },
|
||||
{ title: '状态', dataIndex: 'status', width: 120, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
|
||||
{ title: 'AssetId', dataIndex: 'remoteAssetId', width: 190, render: (v) => <Space><Text code>{shortId(v)}</Text>{v ? <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyText(v)} /> : null}</Space> },
|
||||
{ title: 'GroupId', dataIndex: 'remoteGroupId', width: 190, render: (v) => <Space><Text code>{shortId(v)}</Text>{v ? <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyText(v)} /> : null}</Space> },
|
||||
{ title: '轮询', width: 160, render: (_, r) => <div><Text>次数:{r.pollCount || 0}</Text><br /><Text type="secondary">下次:{formatDate((r as any).nextPollAt)}</Text></div> },
|
||||
{ title: '错误', dataIndex: 'errorMessage', width: 240, ellipsis: true, render: (v) => v ? <Tooltip title={v}><Text type="danger">{v}</Text></Tooltip> : '-' },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: formatDate },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div style={{ padding: 24, background: '#f7f7fb', minHeight: '100%' }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} align="start">
|
||||
<div>
|
||||
<Title level={3} style={{ margin: 0 }}>真人素材库</Title>
|
||||
<Text type="secondary">只读排查页:查看用户项目组、Asset Group 与 Asset 状态。</Text>
|
||||
<Title level={3} style={{ margin: 0 }}>私域人像素材库</Title>
|
||||
<Text type="secondary">统一查看真人认证素材与虚拟人像素材,支持图片/视频资产状态排查。</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索项目/素材"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onPressEnter={() => { setProjectPage(1); setAssetPage(1); loadProjects(); loadAssets(); }}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<Space wrap>
|
||||
<Input allowClear prefix={<SearchOutlined />} placeholder="搜索项目/素材" value={keyword} onChange={(e) => setKeyword(e.target.value)} onPressEnter={searchAll} style={{ width: 240 }} />
|
||||
<Select value={libraryType} onChange={(v) => { setLibraryType(v); setProjectPage(1); setAssetPage(1); }} options={LIBRARY_OPTIONS} style={{ width: 150 }} />
|
||||
<Select value={assetType} onChange={(v) => { setAssetType(v); setAssetPage(1); }} options={ASSET_TYPE_OPTIONS} style={{ width: 130 }} />
|
||||
<Button icon={<SearchOutlined />} type="primary" onClick={searchAll}>查询</Button>
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(); }}>刷新</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Card title="真人素材项目组" style={{ marginBottom: 16 }}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={projectColumns}
|
||||
dataSource={projects}
|
||||
loading={loadingProjects}
|
||||
pagination={{ current: projectPage, pageSize: 20, total: projectTotal, onChange: setProjectPage }}
|
||||
size="small"
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
<Space size={16} wrap style={{ marginBottom: 16 }}>
|
||||
<Card style={{ width: 180 }}><Statistic title="项目数" value={projectTotal} /></Card>
|
||||
<Card style={{ width: 180 }}><Statistic title="当前页素材" value={projectSummary.asset} /></Card>
|
||||
<Card style={{ width: 180 }}><Statistic title="图片" value={projectSummary.image} /></Card>
|
||||
<Card style={{ width: 180 }}><Statistic title="视频" value={projectSummary.video} /></Card>
|
||||
<Card style={{ width: 180 }}><Statistic title="Active" value={projectSummary.active} /></Card>
|
||||
</Space>
|
||||
|
||||
<Card title="项目组" style={{ marginBottom: 16, borderRadius: 14 }} extra={<Select value={projectStatus} onChange={(v) => { setProjectStatus(v); setProjectPage(1); }} options={PROJECT_STATUS_OPTIONS} style={{ width: 180 }} />}>
|
||||
<Table rowKey="id" columns={projectColumns} dataSource={projects} loading={loadingProjects} pagination={{ current: projectPage, pageSize: PAGE_SIZE, total: projectTotal, onChange: setProjectPage }} size="small" scroll={{ x: 1350 }} />
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={selectedProjectId ? '项目素材明细' : '全部素材明细'}
|
||||
extra={selectedProjectId ? <Button size="small" onClick={() => setSelectedProjectId(undefined)}>查看全部</Button> : null}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={assetColumns}
|
||||
dataSource={assets}
|
||||
loading={loadingAssets}
|
||||
pagination={{ current: assetPage, pageSize: 20, total: assetTotal, onChange: setAssetPage }}
|
||||
size="small"
|
||||
scroll={{ x: 1600 }}
|
||||
/>
|
||||
<Card title={selectedProjectId ? '项目素材明细' : '全部素材明细'} style={{ borderRadius: 14 }} extra={<Space><Select value={assetStatus} onChange={(v) => { setAssetStatus(v); setAssetPage(1); }} options={ASSET_STATUS_OPTIONS} style={{ width: 170 }} />{selectedProjectId ? <Button size="small" onClick={() => setSelectedProjectId(undefined)}>查看全部</Button> : null}</Space>}>
|
||||
<Table rowKey="id" columns={assetColumns} dataSource={assets} loading={loadingAssets} pagination={{ current: assetPage, pageSize: PAGE_SIZE, total: assetTotal, onChange: setAssetPage }} size="small" scroll={{ x: 1700 }} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -189,7 +189,7 @@ const AdminUsers: React.FC = () => {
|
||||
credits: values.credits || 0,
|
||||
user_type: userType,
|
||||
frontend_user_kind: values.frontend_user_kind || 'external',
|
||||
private_portrait_image_limit: userType === 'frontend' ? Number(values.private_portrait_image_limit ?? 5) : 0,
|
||||
private_portrait_asset_limit: userType === 'frontend' ? Number(values.private_portrait_asset_limit ?? 5) : 0,
|
||||
});
|
||||
message.success('用户创建成功');
|
||||
setCreateModal(false);
|
||||
@@ -276,13 +276,13 @@ const AdminUsers: React.FC = () => {
|
||||
const openPortraitModal = async (user: AdminUser) => {
|
||||
setPortraitLoading(true);
|
||||
setPortraitModal({ open: true, user, config: null });
|
||||
portraitForm.setFieldsValue({ privatePortraitImageLimit: user.privatePortraitImageLimit ?? 5 });
|
||||
portraitForm.setFieldsValue({ privatePortraitAssetLimit: user.privatePortraitAssetLimit ?? 5 });
|
||||
try {
|
||||
const config = await adminGetPrivatePortraitConfig(user.id);
|
||||
portraitForm.setFieldsValue({ privatePortraitImageLimit: config.imageLimit });
|
||||
portraitForm.setFieldsValue({ privatePortraitAssetLimit: config.assetLimit });
|
||||
setPortraitModal({ open: true, user, config });
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载真人素材库配置失败');
|
||||
message.error(e?.message || '加载私域人像素材库配置失败');
|
||||
setPortraitModal({ open: false, user: null, config: null });
|
||||
} finally {
|
||||
setPortraitLoading(false);
|
||||
@@ -294,16 +294,16 @@ const AdminUsers: React.FC = () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const values = await portraitForm.validateFields();
|
||||
const limit = Number(values.privatePortraitImageLimit ?? 0);
|
||||
const limit = Number(values.privatePortraitAssetLimit ?? 0);
|
||||
setPortraitSaving(true);
|
||||
const config = await adminUpdatePrivatePortraitConfig(user.id, limit);
|
||||
message.success(limit > 0 ? `真人素材库已开启,限制 ${limit} 张` : '真人素材库已关闭');
|
||||
message.success(limit > 0 ? `私域人像素材库已开启,限制 ${limit} 个` : '私域人像素材库已关闭');
|
||||
setPortraitModal({ open: false, user: null, config });
|
||||
portraitForm.resetFields();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '保存真人素材库配置失败');
|
||||
message.error(e?.message || '保存私域人像素材库配置失败');
|
||||
} finally {
|
||||
setPortraitSaving(false);
|
||||
}
|
||||
@@ -424,10 +424,10 @@ const AdminUsers: React.FC = () => {
|
||||
render: (v: string | null | undefined) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary">未分配</Typography.Text>,
|
||||
}] : []),
|
||||
...(!isAdminTab ? [{
|
||||
title: '真人素材库', dataIndex: 'privatePortraitImageLimit', width: 150,
|
||||
title: '私域人像素材库', dataIndex: 'privatePortraitAssetLimit', width: 150,
|
||||
render: (v: number) => {
|
||||
const limit = Number(v || 0);
|
||||
return limit > 0 ? <Tag color="purple">开启:{limit} 张</Tag> : <Tag>未开启</Tag>;
|
||||
return limit > 0 ? <Tag color="purple">开启:{limit} 个</Tag> : <Tag>未开启</Tag>;
|
||||
},
|
||||
}] : []),
|
||||
...(!isAdminTab ? [{
|
||||
@@ -493,7 +493,7 @@ const AdminUsers: React.FC = () => {
|
||||
{!isAdminTab && (
|
||||
<Button type="link" size="small" icon={<PictureOutlined />}
|
||||
onClick={() => openPortraitModal(r)}>
|
||||
真人素材
|
||||
私域人像素材
|
||||
</Button>
|
||||
)}
|
||||
{!isAdminTab && (
|
||||
@@ -784,7 +784,7 @@ const AdminUsers: React.FC = () => {
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={<Space><PictureOutlined />真人素材库设置 - {portraitModal.user?.username}</Space>}
|
||||
title={<Space><PictureOutlined />私域人像素材库设置 - {portraitModal.user?.username}</Space>}
|
||||
open={portraitModal.open}
|
||||
confirmLoading={portraitSaving}
|
||||
onOk={handleSavePortraitConfig}
|
||||
@@ -797,17 +797,17 @@ const AdminUsers: React.FC = () => {
|
||||
当前状态:{portraitModal.config?.enabled ? <Tag color="purple">已开启</Tag> : <Tag>未开启</Tag>}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
已用:{portraitModal.config?.usedImageCount ?? '-'} 张;
|
||||
剩余:{portraitModal.config?.enabled ? portraitModal.config.remainingImageCount : 0} 张
|
||||
已用:{portraitModal.config?.usedAssetCount ?? '-'} 个;
|
||||
剩余:{portraitModal.config?.enabled ? portraitModal.config.remainingAssetCount : 0} 个
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Card>
|
||||
<Form form={portraitForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="privatePortraitImageLimit"
|
||||
label="真人素材库图片上限"
|
||||
extra="0 表示关闭真人素材库;大于 0 表示开启,并限制该用户所有真人素材图片总量。"
|
||||
rules={[{ required: true, message: '请输入真人素材库图片上限' }]}
|
||||
name="privatePortraitAssetLimit"
|
||||
label="私域人像素材总量上限"
|
||||
extra="0 表示关闭私域人像素材库;大于 0 表示开启,并限制该用户所有私域人像素材总量。"
|
||||
rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
|
||||
>
|
||||
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
@@ -857,11 +857,11 @@ const AdminUsers: React.FC = () => {
|
||||
)}
|
||||
{createType === 'frontend' && (
|
||||
<Form.Item
|
||||
name="private_portrait_image_limit"
|
||||
label="真人素材库图片上限"
|
||||
name="private_portrait_asset_limit"
|
||||
label="私域人像素材总量上限"
|
||||
initialValue={5}
|
||||
extra="0 表示关闭真人素材库;大于 0 表示开启并限制该用户所有真人素材图片总量。"
|
||||
rules={[{ required: true, message: '请输入真人素材库图片上限' }]}
|
||||
extra="0 表示关闭私域人像素材库;大于 0 表示开启并限制该用户所有私域人像素材总量。"
|
||||
rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
|
||||
>
|
||||
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface User {
|
||||
userType: string;
|
||||
allowedMenus?: string[] | null;
|
||||
resourceCapacity?: ResourceCapacityUsage | null;
|
||||
privatePortraitImageLimit: number;
|
||||
privatePortraitAssetLimit: number;
|
||||
}
|
||||
|
||||
export interface CreditRecord {
|
||||
@@ -194,7 +194,7 @@ export interface AdminUser {
|
||||
lastLoginAt?: string;
|
||||
allowedMenus?: string[] | null;
|
||||
resourceCapacity?: ResourceCapacityUsage | null;
|
||||
privatePortraitImageLimit: number;
|
||||
privatePortraitAssetLimit: number;
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
@@ -1125,14 +1125,20 @@ export interface HomeMaterialTextWatermarkPreviewResponse {
|
||||
|
||||
export interface PrivatePortraitConfig {
|
||||
enabled: boolean;
|
||||
imageLimit: number;
|
||||
usedImageCount: number;
|
||||
remainingImageCount: number;
|
||||
assetLimit: number;
|
||||
usedAssetCount: number;
|
||||
remainingAssetCount: number;
|
||||
supportedAssetTypes?: string[];
|
||||
unsupportedAssetTypes?: string[];
|
||||
imageLimit?: number;
|
||||
usedImageCount?: number;
|
||||
remainingImageCount?: number;
|
||||
}
|
||||
|
||||
export interface PrivatePortraitProject {
|
||||
id: string;
|
||||
userId?: string | null;
|
||||
libraryType: string;
|
||||
name: string;
|
||||
nameSlug?: string | null;
|
||||
remoteProjectName?: string | null;
|
||||
@@ -1140,7 +1146,11 @@ export interface PrivatePortraitProject {
|
||||
status: string;
|
||||
assetGroupCount: number;
|
||||
assetCount: number;
|
||||
imageAssetCount?: number;
|
||||
videoAssetCount?: number;
|
||||
activeAssetCount: number;
|
||||
activeImageAssetCount?: number;
|
||||
activeVideoAssetCount?: number;
|
||||
lastUsedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
@@ -1174,6 +1184,7 @@ export interface PrivatePortraitAsset {
|
||||
projectId: string;
|
||||
projectName?: string | null;
|
||||
groupId: string;
|
||||
libraryType: string;
|
||||
remoteGroupId: string;
|
||||
remoteAssetId?: string | null;
|
||||
remoteProjectName?: string | null;
|
||||
@@ -1181,7 +1192,13 @@ export interface PrivatePortraitAsset {
|
||||
name?: string | null;
|
||||
sourceUrl: string;
|
||||
previewUrl?: string | null;
|
||||
displayUrl?: string | null;
|
||||
providerUrl?: string | null;
|
||||
remoteUrl?: string | null;
|
||||
videoDuration?: number | null;
|
||||
videoCoverUrl?: string | null;
|
||||
fileSize?: number | null;
|
||||
mimeType?: string | null;
|
||||
status: string;
|
||||
pollCount: number;
|
||||
remoteDeleteStatus: string;
|
||||
@@ -1201,9 +1218,14 @@ export interface PrivatePortraitSelectableAsset {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
libraryType: string;
|
||||
name?: string | null;
|
||||
assetType: string;
|
||||
previewUrl?: string | null;
|
||||
displayUrl?: string | null;
|
||||
providerUrl?: string | null;
|
||||
videoDuration?: number | null;
|
||||
videoCoverUrl?: string | null;
|
||||
status: string;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ VIDEO_COVER_WIDTH=600
|
||||
VIDEO_COVER_TIMEOUT_SECONDS=15
|
||||
VIDEO_COVER_FORMAT=png
|
||||
|
||||
# VOLC
|
||||
VOLC_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
|
||||
VOLC_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
|
||||
|
||||
# VOLC_SMS
|
||||
VOLC_SMS_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
|
||||
VOLC_SMS_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
|
||||
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
"""rename private portrait asset limit and add library type
|
||||
|
||||
Revision ID: cd7688999204
|
||||
Revises: e7038d02c355
|
||||
Create Date: 2026-07-07 13:21:49.612465
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "cd7688999204"
|
||||
down_revision: Union[str, None] = "e7038d02c355"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. users 字段改名:必须 rename,保留原有用户额度数据
|
||||
op.alter_column(
|
||||
"users",
|
||||
"private_portrait_image_limit",
|
||||
new_column_name="private_portrait_asset_limit",
|
||||
existing_type=sa.Integer(),
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text("5"),
|
||||
comment="私域人像素材总量限制,真人/虚拟共用,图片/视频共用,0 表示关闭",
|
||||
existing_comment="私域真人图片素材数量限制,0表示关闭",
|
||||
)
|
||||
|
||||
# 2. private_portrait_asset_groups
|
||||
op.add_column(
|
||||
"private_portrait_asset_groups",
|
||||
sa.Column(
|
||||
"library_type",
|
||||
sa.String(length=32),
|
||||
server_default="real_person",
|
||||
nullable=False,
|
||||
comment="素材库类型:real_person 真人认证;aigc_virtual 虚拟人像",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_private_portrait_asset_groups_library_type",
|
||||
"private_portrait_asset_groups",
|
||||
["library_type"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"idx_private_portrait_asset_groups_user_library",
|
||||
"private_portrait_asset_groups",
|
||||
["user_id", "library_type"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
# 3. private_portrait_assets
|
||||
op.add_column(
|
||||
"private_portrait_assets",
|
||||
sa.Column(
|
||||
"library_type",
|
||||
sa.String(length=32),
|
||||
server_default="real_person",
|
||||
nullable=False,
|
||||
comment="素材库类型:real_person 真人认证;aigc_virtual 虚拟人像",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"private_portrait_assets",
|
||||
sa.Column("video_duration", sa.Float(), nullable=True, comment="视频素材时长,秒"),
|
||||
)
|
||||
op.add_column(
|
||||
"private_portrait_assets",
|
||||
sa.Column("video_cover_url", sa.Text(), nullable=True, comment="视频素材封面预览地址"),
|
||||
)
|
||||
op.add_column(
|
||||
"private_portrait_assets",
|
||||
sa.Column("file_size", sa.Integer(), nullable=True, comment="素材文件大小,字节"),
|
||||
)
|
||||
op.add_column(
|
||||
"private_portrait_assets",
|
||||
sa.Column("mime_type", sa.String(length=128), nullable=True, comment="素材 MIME 类型"),
|
||||
)
|
||||
|
||||
# 如果 private_portrait_assets.asset_type 已经存在索引,这条要删除
|
||||
op.create_index(
|
||||
"idx_private_portrait_assets_asset_type",
|
||||
"private_portrait_assets",
|
||||
["asset_type"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"idx_private_portrait_assets_library_type",
|
||||
"private_portrait_assets",
|
||||
["library_type"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"idx_private_portrait_assets_user_library_status_created",
|
||||
"private_portrait_assets",
|
||||
["user_id", "library_type", "status", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
# 4. private_portrait_projects
|
||||
op.add_column(
|
||||
"private_portrait_projects",
|
||||
sa.Column(
|
||||
"library_type",
|
||||
sa.String(length=32),
|
||||
server_default="real_person",
|
||||
nullable=False,
|
||||
comment="素材库类型:real_person 真人认证;aigc_virtual 虚拟人像",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"private_portrait_projects",
|
||||
sa.Column("image_asset_count", sa.Integer(), server_default="0", nullable=False, comment="图片素材数量"),
|
||||
)
|
||||
op.add_column(
|
||||
"private_portrait_projects",
|
||||
sa.Column("video_asset_count", sa.Integer(), server_default="0", nullable=False, comment="视频素材数量"),
|
||||
)
|
||||
op.add_column(
|
||||
"private_portrait_projects",
|
||||
sa.Column("active_image_asset_count", sa.Integer(), server_default="0", nullable=False, comment="可用图片素材数量"),
|
||||
)
|
||||
op.add_column(
|
||||
"private_portrait_projects",
|
||||
sa.Column("active_video_asset_count", sa.Integer(), server_default="0", nullable=False, comment="可用视频素材数量"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_private_portrait_projects_library_type",
|
||||
"private_portrait_projects",
|
||||
["library_type"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"idx_private_portrait_projects_user_library_status_created",
|
||||
"private_portrait_projects",
|
||||
["user_id", "library_type", "status", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 1. users 字段反向改名:保留数据
|
||||
op.alter_column(
|
||||
"users",
|
||||
"private_portrait_asset_limit",
|
||||
new_column_name="private_portrait_image_limit",
|
||||
existing_type=sa.Integer(),
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text("5"),
|
||||
comment="私域真人图片素材数量限制,0表示关闭",
|
||||
existing_comment="私域人像素材总量限制,真人/虚拟共用,图片/视频共用,0 表示关闭",
|
||||
)
|
||||
|
||||
# 2. private_portrait_projects
|
||||
op.drop_index(
|
||||
"idx_private_portrait_projects_user_library_status_created",
|
||||
table_name="private_portrait_projects",
|
||||
)
|
||||
op.drop_index(
|
||||
"idx_private_portrait_projects_library_type",
|
||||
table_name="private_portrait_projects",
|
||||
)
|
||||
op.drop_column("private_portrait_projects", "active_video_asset_count")
|
||||
op.drop_column("private_portrait_projects", "active_image_asset_count")
|
||||
op.drop_column("private_portrait_projects", "video_asset_count")
|
||||
op.drop_column("private_portrait_projects", "image_asset_count")
|
||||
op.drop_column("private_portrait_projects", "library_type")
|
||||
|
||||
# 3. private_portrait_assets
|
||||
op.drop_index(
|
||||
"idx_private_portrait_assets_user_library_status_created",
|
||||
table_name="private_portrait_assets",
|
||||
)
|
||||
op.drop_index(
|
||||
"idx_private_portrait_assets_library_type",
|
||||
table_name="private_portrait_assets",
|
||||
)
|
||||
op.drop_index(
|
||||
"idx_private_portrait_assets_asset_type",
|
||||
table_name="private_portrait_assets",
|
||||
)
|
||||
op.drop_column("private_portrait_assets", "mime_type")
|
||||
op.drop_column("private_portrait_assets", "file_size")
|
||||
op.drop_column("private_portrait_assets", "video_cover_url")
|
||||
op.drop_column("private_portrait_assets", "video_duration")
|
||||
op.drop_column("private_portrait_assets", "library_type")
|
||||
|
||||
# 4. private_portrait_asset_groups
|
||||
op.drop_index(
|
||||
"idx_private_portrait_asset_groups_user_library",
|
||||
table_name="private_portrait_asset_groups",
|
||||
)
|
||||
op.drop_index(
|
||||
"idx_private_portrait_asset_groups_library_type",
|
||||
table_name="private_portrait_asset_groups",
|
||||
)
|
||||
op.drop_column("private_portrait_asset_groups", "library_type")
|
||||
@@ -10,127 +10,59 @@ from app.dependencies import get_admin_user, get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.private_portrait import (
|
||||
PrivatePortraitAdminConfigUpdate,
|
||||
PrivatePortraitAdminStatsOut,
|
||||
PrivatePortraitAssetListOut,
|
||||
PrivatePortraitConfigOut,
|
||||
PrivatePortraitProjectListOut,
|
||||
)
|
||||
from app.services.operation_log import log_operation
|
||||
from app.services.private_portrait.asset_service import (
|
||||
asset_to_out,
|
||||
get_user_private_portrait_config,
|
||||
list_assets,
|
||||
set_user_private_portrait_limit,
|
||||
)
|
||||
from app.services.private_portrait.project_service import list_projects, project_to_out
|
||||
from app.services.private_portrait.admin.asset_query_service import admin_list_assets
|
||||
from app.services.private_portrait.admin.project_query_service import admin_list_projects
|
||||
from app.services.private_portrait.admin.stats_query_service import admin_get_private_portrait_stats
|
||||
from app.services.private_portrait.quota_service import get_user_private_portrait_config, set_user_private_portrait_limit
|
||||
|
||||
router = APIRouter(prefix="/admin/private-portrait", tags=["admin-private-portrait"])
|
||||
router = APIRouter(prefix="/admin/private-portrait", tags=["管理后台-私域人像素材库"])
|
||||
|
||||
|
||||
def _json_detail(data: dict[str, Any]) -> str:
|
||||
return json.dumps(data, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/config", response_model=PrivatePortraitConfigOut)
|
||||
async def admin_get_private_portrait_config(
|
||||
user_id: str,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@router.get("/users/{user_id}/config", response_model=PrivatePortraitConfigOut, summary="管理后台:查看用户私域人像素材额度")
|
||||
async def admin_get_private_portrait_config(user_id: str, admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||||
config = await get_user_private_portrait_config(db, user_id=user_id)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
"查看真人素材库配置",
|
||||
"GET",
|
||||
f"/admin/private-portrait/users/{user_id}/config",
|
||||
detail=_json_detail({"target_user_id": user_id, "config": config.model_dump(mode="json")}),
|
||||
)
|
||||
await log_operation(db, admin.id, admin.username, "查看私域人像素材库配置", "GET", f"/admin/private-portrait/users/{user_id}/config", detail=_json_detail({"target_user_id": user_id, "config": config.model_dump(mode="json")}))
|
||||
return config
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/config", response_model=PrivatePortraitConfigOut)
|
||||
async def admin_update_private_portrait_config(
|
||||
user_id: str,
|
||||
payload: PrivatePortraitAdminConfigUpdate,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@router.put("/users/{user_id}/config", response_model=PrivatePortraitConfigOut, summary="管理后台:设置用户私域人像素材总量")
|
||||
async def admin_update_private_portrait_config(user_id: str, payload: PrivatePortraitAdminConfigUpdate, admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||||
before = await get_user_private_portrait_config(db, user_id=user_id)
|
||||
await set_user_private_portrait_limit(db, user_id=user_id, limit=payload.private_portrait_image_limit)
|
||||
await set_user_private_portrait_limit(db, user_id=user_id, limit=payload.private_portrait_asset_limit)
|
||||
after = await get_user_private_portrait_config(db, user_id=user_id)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"设置真人素材库数量限制:{before.image_limit} -> {after.image_limit}",
|
||||
"PUT",
|
||||
f"/admin/private-portrait/users/{user_id}/config",
|
||||
detail=_json_detail(
|
||||
{
|
||||
"target_user_id": user_id,
|
||||
"before": before.model_dump(mode="json"),
|
||||
"after": after.model_dump(mode="json"),
|
||||
}
|
||||
),
|
||||
)
|
||||
await log_operation(db, admin.id, admin.username, f"设置私域人像素材总量限制:{before.asset_limit} -> {after.asset_limit}", "PUT", f"/admin/private-portrait/users/{user_id}/config", detail=_json_detail({"target_user_id": user_id, "before": before.model_dump(mode="json"), "after": after.model_dump(mode="json")}))
|
||||
await db.commit()
|
||||
return after
|
||||
|
||||
|
||||
@router.get("/projects", response_model=PrivatePortraitProjectListOut)
|
||||
async def admin_list_private_portrait_projects(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user_id: str | None = Query(None),
|
||||
keyword: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
items, total = await list_projects(db, user_id=user_id, page=page, page_size=page_size, keyword=keyword, status=status)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
"查看真人素材项目列表",
|
||||
"GET",
|
||||
"/admin/private-portrait/projects",
|
||||
detail=_json_detail(
|
||||
{
|
||||
"filters": {"user_id": user_id, "keyword": keyword, "status": status, "page": page, "page_size": page_size},
|
||||
"total": total,
|
||||
"returned_count": len(items),
|
||||
}
|
||||
),
|
||||
)
|
||||
return PrivatePortraitProjectListOut(items=[project_to_out(item, include_user=True) for item in items], total=total, page=page, page_size=page_size)
|
||||
@router.get("/stats", response_model=PrivatePortraitAdminStatsOut, summary="管理后台:私域人像素材统计")
|
||||
async def admin_get_private_portrait_stats_api(user_id: str | None = Query(None), library_type: str | None = Query(None), admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||||
stats = await admin_get_private_portrait_stats(db, user_id=user_id, library_type=library_type)
|
||||
await log_operation(db, admin.id, admin.username, "查看私域人像素材统计", "GET", "/admin/private-portrait/stats", detail=_json_detail({"filters": {"user_id": user_id, "library_type": library_type}, "stats": stats.model_dump(mode="json")}))
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/assets", response_model=PrivatePortraitAssetListOut)
|
||||
async def admin_list_private_portrait_assets(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user_id: str | None = Query(None),
|
||||
project_id: str | None = Query(None),
|
||||
keyword: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
assets, total, project_name_map = await list_assets(db, user_id=user_id, project_id=project_id, status=status, keyword=keyword, page=page, page_size=page_size)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
"查看真人素材列表",
|
||||
"GET",
|
||||
"/admin/private-portrait/assets",
|
||||
detail=_json_detail(
|
||||
{
|
||||
"filters": {"user_id": user_id, "project_id": project_id, "keyword": keyword, "status": status, "page": page, "page_size": page_size},
|
||||
"total": total,
|
||||
"returned_count": len(assets),
|
||||
}
|
||||
),
|
||||
)
|
||||
return PrivatePortraitAssetListOut(items=[asset_to_out(asset, project_name=project_name_map.get(asset.project_id), include_user=True) for asset in assets], total=total, page=page, page_size=page_size)
|
||||
@router.get("/projects", response_model=PrivatePortraitProjectListOut, summary="管理后台:查询私域人像项目列表")
|
||||
async def admin_list_private_portrait_projects(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), user_id: str | None = Query(None), library_type: str | None = Query(None), keyword: str | None = Query(None), status: str | None = Query(None), admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||||
items, total = await admin_list_projects(db, user_id=user_id, library_type=library_type, page=page, page_size=page_size, keyword=keyword, status=status)
|
||||
await log_operation(db, admin.id, admin.username, "查看私域人像素材项目列表", "GET", "/admin/private-portrait/projects", detail=_json_detail({"filters": {"user_id": user_id, "library_type": library_type, "keyword": keyword, "status": status, "page": page, "page_size": page_size}, "total": total, "returned_count": len(items)}))
|
||||
await db.commit()
|
||||
return PrivatePortraitProjectListOut(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/assets", response_model=PrivatePortraitAssetListOut, summary="管理后台:查询私域人像素材列表")
|
||||
async def admin_list_private_portrait_assets(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), user_id: str | None = Query(None), project_id: str | None = Query(None), library_type: str | None = Query(None), asset_type: str | None = Query(None), keyword: str | None = Query(None), status: str | None = Query(None), admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||||
items, total = await admin_list_assets(db, user_id=user_id, project_id=project_id, library_type=library_type, asset_type=asset_type, status=status, keyword=keyword, page=page, page_size=page_size)
|
||||
await log_operation(db, admin.id, admin.username, "查看私域人像素材列表", "GET", "/admin/private-portrait/assets", detail=_json_detail({"filters": {"user_id": user_id, "project_id": project_id, "library_type": library_type, "asset_type": asset_type, "keyword": keyword, "status": status, "page": page, "page_size": page_size}, "total": total, "returned_count": len(items)}))
|
||||
await db.commit()
|
||||
return PrivatePortraitAssetListOut(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
@@ -33,6 +33,7 @@ from app.api.v1.home_materials import router as home_materials_router
|
||||
from app.api.admin import router as admin_module_router
|
||||
from app.api.v1.material_admin import router as material_admin_router
|
||||
from app.api.v1.private_portrait import router as private_portrait_router
|
||||
from app.api.v1.private_portrait_virtual import router as private_portrait_virtual_router
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(auth_router)
|
||||
@@ -68,3 +69,4 @@ api_router.include_router(home_materials_router)
|
||||
api_router.include_router(admin_module_router)
|
||||
api_router.include_router(material_admin_router)
|
||||
api_router.include_router(private_portrait_router)
|
||||
api_router.include_router(private_portrait_virtual_router)
|
||||
|
||||
@@ -174,7 +174,7 @@ async def create_user(
|
||||
user_type=req.user_type,
|
||||
frontend_user_kind=req.frontend_user_kind if req.user_type == "frontend" else FrontendUserKind.EXTERNAL.value,
|
||||
allowed_menus=req.allowed_menus,
|
||||
private_portrait_image_limit=req.private_portrait_image_limit,
|
||||
private_portrait_asset_limit=req.private_portrait_asset_limit,
|
||||
)
|
||||
user.credits = round(user.credits, 2)
|
||||
db.add(user)
|
||||
@@ -192,7 +192,7 @@ async def create_user(
|
||||
"username": username,
|
||||
"user_type": req.user_type,
|
||||
"frontend_user_kind": user.frontend_user_kind,
|
||||
"private_portrait_image_limit": user.private_portrait_image_limit,
|
||||
"private_portrait_asset_limit": user.private_portrait_asset_limit,
|
||||
"credits": user.credits,
|
||||
"phone": user.phone,
|
||||
"email": user.email,
|
||||
|
||||
@@ -9,9 +9,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.enums.private_portrait import (
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitProjectStatus,
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
)
|
||||
@@ -20,6 +22,7 @@ from app.models.user import User
|
||||
from app.schemas.private_portrait import (
|
||||
PrivatePortraitAssetCreate,
|
||||
PrivatePortraitAssetListOut,
|
||||
PrivatePortraitAssetOut,
|
||||
PrivatePortraitDeleteOut,
|
||||
PrivatePortraitConfigOut,
|
||||
PrivatePortraitProjectCreate,
|
||||
@@ -30,13 +33,13 @@ from app.schemas.private_portrait import (
|
||||
PrivatePortraitSelectableAssetListOut,
|
||||
PrivatePortraitValidateSessionCreate,
|
||||
PrivatePortraitValidateSessionOut,
|
||||
PrivatePortraitEnumMetaOut,
|
||||
build_private_portrait_enum_meta,
|
||||
)
|
||||
from app.services.operation_log_service import log_operation_error, log_operation_event
|
||||
from app.services.private_portrait.asset_service import (
|
||||
DOMAIN,
|
||||
asset_to_out,
|
||||
create_asset,
|
||||
create_validate_session,
|
||||
get_user_private_portrait_config,
|
||||
get_validate_session,
|
||||
handle_validate_callback,
|
||||
@@ -47,100 +50,102 @@ from app.services.private_portrait.asset_service import (
|
||||
validate_session_to_out,
|
||||
)
|
||||
from app.services.private_portrait.project_service import (
|
||||
create_project,
|
||||
get_user_project,
|
||||
list_projects,
|
||||
project_to_out,
|
||||
refresh_project_counters,
|
||||
soft_delete_project,
|
||||
update_project,
|
||||
)
|
||||
from app.services.private_portrait.real_person.service import (
|
||||
create_real_person_asset,
|
||||
create_real_person_project,
|
||||
create_real_person_validate_session,
|
||||
update_real_person_project,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["private-portrait"])
|
||||
router = APIRouter(tags=["私域真人素材库"])
|
||||
|
||||
|
||||
def _log_task_dispatch_failed(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None, exc: BaseException) -> None:
|
||||
log_operation_error(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.TASK_DISPATCH_FAILED.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
asset_id=asset_id,
|
||||
exc=exc,
|
||||
detail={"task_name": task_name},
|
||||
)
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.TASK_DISPATCH_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, asset_id=asset_id, exc=exc, detail={"task_name": task_name})
|
||||
|
||||
|
||||
def _log_task_dispatch_success(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None) -> None:
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.TASK_DISPATCH_SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
asset_id=asset_id,
|
||||
detail={"task_name": task_name},
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.TASK_DISPATCH_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, asset_id=asset_id, detail={"task_name": task_name})
|
||||
|
||||
|
||||
@router.get("/private-portrait/config", response_model=PrivatePortraitConfigOut)
|
||||
@router.get(
|
||||
"/private-portrait/config",
|
||||
response_model=PrivatePortraitConfigOut,
|
||||
summary="获取当前用户私域人像素材额度配置",
|
||||
description="返回私域人像素材总量限制。额度由真人认证素材库与虚拟人像素材库共用,图片和视频共用,Audio 暂未开放。",
|
||||
)
|
||||
async def get_my_private_portrait_config(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
return await get_user_private_portrait_config(db, user_id=current_user.id)
|
||||
|
||||
|
||||
@router.post("/private-portrait/projects", response_model=PrivatePortraitProjectCreateWithValidateOut)
|
||||
@router.get(
|
||||
"/private-portrait/meta/enums",
|
||||
response_model=PrivatePortraitEnumMetaOut,
|
||||
summary="获取私域人像素材库枚举说明",
|
||||
description="给前端展示状态、类型、素材库类型使用。Audio 仅作为火山支持项展示,当前业务不开放上传。",
|
||||
)
|
||||
async def get_private_portrait_enum_meta():
|
||||
return build_private_portrait_enum_meta()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/private-portrait/projects",
|
||||
response_model=PrivatePortraitProjectCreateWithValidateOut,
|
||||
summary="创建真人认证素材项目并生成认证会话",
|
||||
description="创建本地真人素材项目,随后调用火山 CreateVisualValidateSession 返回 H5Link。用户完成认证后,回调会创建本地 Asset Group 映射。",
|
||||
)
|
||||
async def create_private_portrait_project(payload: PrivatePortraitProjectCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
project = await create_project(db, user_id=current_user.id, payload=payload)
|
||||
session = await create_validate_session(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
project_id=project.id,
|
||||
callback_redirect_url=payload.callback_redirect_url,
|
||||
)
|
||||
out = PrivatePortraitProjectCreateWithValidateOut(
|
||||
project=project_to_out(project),
|
||||
validate_session=validate_session_to_out(session),
|
||||
poll_interval_ms=2000,
|
||||
)
|
||||
project = await create_real_person_project(db, user_id=current_user.id, payload=payload)
|
||||
session = await create_real_person_validate_session(db, user_id=current_user.id, project_id=project.id, callback_redirect_url=payload.callback_redirect_url)
|
||||
out = PrivatePortraitProjectCreateWithValidateOut(project=project_to_out(project), validate_session=validate_session_to_out(session), poll_interval_ms=2000)
|
||||
await db.commit()
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/private-portrait/projects", response_model=PrivatePortraitProjectListOut)
|
||||
@router.get(
|
||||
"/private-portrait/projects",
|
||||
response_model=PrivatePortraitProjectListOut,
|
||||
summary="查询当前用户真人认证素材项目列表",
|
||||
description="只返回 library_type=real_person 的项目。默认查询 active 项目,可用 status 覆盖。",
|
||||
)
|
||||
async def list_private_portrait_projects(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
keyword: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
page: int = Query(1, ge=1, description="页码,从 1 开始。"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100。"),
|
||||
keyword: str | None = Query(None, description="项目名称模糊搜索。"),
|
||||
status: str | None = Query(None, description="项目状态,不传默认 active。"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query_status = status or PrivatePortraitProjectStatus.ACTIVE.value
|
||||
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=query_status)
|
||||
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=query_status, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
await refresh_project_counters(db, [item.id for item in items])
|
||||
await db.commit()
|
||||
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=query_status)
|
||||
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=query_status, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
return PrivatePortraitProjectListOut(items=[project_to_out(item) for item in items], total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut)
|
||||
@router.get("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="获取真人认证素材项目详情")
|
||||
async def get_private_portrait_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
return project_to_out(await get_user_project(db, user_id=current_user.id, project_id=project_id))
|
||||
return project_to_out(await get_user_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value))
|
||||
|
||||
|
||||
@router.put("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut)
|
||||
@router.put("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="更新真人认证素材项目")
|
||||
async def update_private_portrait_project(project_id: str, payload: PrivatePortraitProjectUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
project = await update_project(db, user_id=current_user.id, project_id=project_id, payload=payload)
|
||||
project = await update_real_person_project(db, user_id=current_user.id, project_id=project_id, payload=payload)
|
||||
out = project_to_out(project)
|
||||
await db.commit()
|
||||
return out
|
||||
|
||||
|
||||
@router.delete("/private-portrait/projects/{project_id}", response_model=PrivatePortraitDeleteOut)
|
||||
@router.delete("/private-portrait/projects/{project_id}", response_model=PrivatePortraitDeleteOut, summary="删除真人认证素材项目")
|
||||
async def delete_private_portrait_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
project = await soft_delete_project(db, user_id=current_user.id, project_id=project_id)
|
||||
project = await soft_delete_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
project_id_snapshot = project.id
|
||||
await db.commit()
|
||||
try:
|
||||
@@ -153,30 +158,26 @@ async def delete_private_portrait_project(project_id: str, current_user: User =
|
||||
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
|
||||
|
||||
|
||||
@router.post("/private-portrait/projects/{project_id}/validate-sessions", response_model=PrivatePortraitValidateSessionOut)
|
||||
@router.post("/private-portrait/projects/{project_id}/validate-sessions", response_model=PrivatePortraitValidateSessionOut, summary="重新创建真人认证会话")
|
||||
async def create_private_portrait_validate_session(project_id: str, payload: PrivatePortraitValidateSessionCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
session = await create_validate_session(db, user_id=current_user.id, project_id=project_id, callback_redirect_url=payload.callback_redirect_url)
|
||||
session = await create_real_person_validate_session(db, user_id=current_user.id, project_id=project_id, callback_redirect_url=payload.callback_redirect_url)
|
||||
out = validate_session_to_out(session)
|
||||
await db.commit()
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/private-portrait/validate-sessions/{session_id}", response_model=PrivatePortraitValidateSessionOut)
|
||||
@router.get("/private-portrait/validate-sessions/{session_id}", response_model=PrivatePortraitValidateSessionOut, summary="查询真人认证会话状态")
|
||||
async def get_private_portrait_validate_session(session_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
return validate_session_to_out(await get_validate_session(db, user_id=current_user.id, session_id=session_id))
|
||||
|
||||
|
||||
@router.get("/private-portrait/validate-callback")
|
||||
@router.get("/private-portrait/validate-callback", summary="火山真人认证回调入口")
|
||||
async def private_portrait_validate_callback(session_id: str, request: Request, redirect_url: str | None = None, db: AsyncSession = Depends(get_db)):
|
||||
params = dict(request.query_params)
|
||||
params.pop("session_id", None)
|
||||
params.pop("redirect_url", None)
|
||||
session = await handle_validate_callback(db, session_id=session_id, query_params=params)
|
||||
redirect_params = {
|
||||
"session_id": session.id,
|
||||
"status": session.status,
|
||||
"resultCode": session.result_code or "",
|
||||
}
|
||||
redirect_params = {"session_id": session.id, "status": session.status, "resultCode": session.result_code or ""}
|
||||
if session.remote_group_id:
|
||||
redirect_params["remote_group_id"] = session.remote_group_id
|
||||
response = {"session_id": session.id, "status": session.status, "resultCode": session.result_code, "remote_group_id": session.remote_group_id}
|
||||
@@ -188,9 +189,9 @@ async def private_portrait_validate_callback(session_id: str, request: Request,
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/private-portrait/projects/{project_id}/assets")
|
||||
@router.post("/private-portrait/projects/{project_id}/assets", response_model=PrivatePortraitAssetOut, summary="上传真人认证素材", description="当前支持 Image / Video。Audio 暂不开放。CreateAsset 是异步接口,返回后需要轮询到 Active 才可用于生成。")
|
||||
async def create_private_portrait_asset(project_id: str, payload: PrivatePortraitAssetCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
asset = await create_asset(db, user_id=current_user.id, project_id=project_id, payload=payload)
|
||||
asset = await create_real_person_asset(db, user_id=current_user.id, project_id=project_id, payload=payload)
|
||||
asset_id_snapshot = asset.id
|
||||
project_id_snapshot = asset.project_id
|
||||
out = asset_to_out(asset)
|
||||
@@ -205,32 +206,34 @@ async def create_private_portrait_asset(project_id: str, payload: PrivatePortrai
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/private-portrait/projects/{project_id}/assets", response_model=PrivatePortraitAssetListOut)
|
||||
async def list_private_portrait_assets(project_id: str, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status: str | None = Query(None), keyword: str | None = Query(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
assets, total, project_name_map = await list_assets(db, user_id=current_user.id, project_id=project_id, status=status, keyword=keyword, page=page, page_size=page_size)
|
||||
@router.get("/private-portrait/projects/{project_id}/assets", response_model=PrivatePortraitAssetListOut, summary="查询真人认证素材列表")
|
||||
async def list_private_portrait_assets(project_id: str, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status: str | None = Query(None), keyword: str | None = Query(None), asset_type: str | None = Query(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
assets, total, project_name_map = await list_assets(db, user_id=current_user.id, project_id=project_id, status=status, keyword=keyword, page=page, page_size=page_size, library_type=PrivatePortraitLibraryType.REAL_PERSON.value, asset_type=asset_type)
|
||||
return PrivatePortraitAssetListOut(items=[asset_to_out(asset, project_name=project_name_map.get(asset.project_id)) for asset in assets], total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/private-portrait/assets/{asset_id}")
|
||||
@router.get("/private-portrait/assets/{asset_id}", summary="获取真人认证素材详情")
|
||||
async def get_private_portrait_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == current_user.id).limit(1))).scalar_one_or_none()
|
||||
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == current_user.id, PrivatePortraitAsset.library_type == PrivatePortraitLibraryType.REAL_PERSON.value).limit(1))).scalar_one_or_none()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="真人素材不存在")
|
||||
raise HTTPException(status_code=404, detail="私域人像素材不存在")
|
||||
project = (await db.execute(select(PrivatePortraitProject).where(PrivatePortraitProject.id == asset.project_id).limit(1))).scalar_one_or_none()
|
||||
return asset_to_out(asset, project_name=project.name if project else None)
|
||||
|
||||
|
||||
@router.post("/private-portrait/assets/{asset_id}/sync")
|
||||
@router.post("/private-portrait/assets/{asset_id}/sync", summary="同步真人认证素材状态")
|
||||
async def sync_private_portrait_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
asset = await sync_asset_status(db, user_id=current_user.id, asset_id=asset_id)
|
||||
if asset.library_type != PrivatePortraitLibraryType.REAL_PERSON.value:
|
||||
raise HTTPException(status_code=404, detail="私域人像素材不存在")
|
||||
out = asset_to_out(asset)
|
||||
await db.commit()
|
||||
return out
|
||||
|
||||
|
||||
@router.delete("/private-portrait/assets/{asset_id}", response_model=PrivatePortraitDeleteOut)
|
||||
@router.delete("/private-portrait/assets/{asset_id}", response_model=PrivatePortraitDeleteOut, summary="删除真人认证素材")
|
||||
async def delete_private_portrait_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
asset = await soft_delete_asset(db, user_id=current_user.id, asset_id=asset_id)
|
||||
asset = await soft_delete_asset(db, user_id=current_user.id, asset_id=asset_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
asset_id_snapshot = asset.id
|
||||
project_id_snapshot = asset.project_id
|
||||
await db.commit()
|
||||
@@ -244,7 +247,7 @@ async def delete_private_portrait_asset(asset_id: str, current_user: User = Depe
|
||||
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
|
||||
|
||||
|
||||
@router.get("/private-portrait/selectable-assets", response_model=PrivatePortraitSelectableAssetListOut)
|
||||
async def list_private_portrait_selectable_assets(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), project_id: str | None = Query(None), keyword: str | None = Query(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
items, total = await list_selectable_assets(db, user_id=current_user.id, project_id=project_id, keyword=keyword, page=page, page_size=page_size)
|
||||
@router.get("/private-portrait/selectable-assets", response_model=PrivatePortraitSelectableAssetListOut, summary="查询可用于生成的真人认证素材")
|
||||
async def list_private_portrait_selectable_assets(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), project_id: str | None = Query(None), keyword: str | None = Query(None), asset_type: str | None = Query(None, description="Image 或 Video,不传查全部。"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
items, total = await list_selectable_assets(db, user_id=current_user.id, project_id=project_id, keyword=keyword, page=page, page_size=page_size, library_type=PrivatePortraitLibraryType.REAL_PERSON.value, asset_type=asset_type)
|
||||
return PrivatePortraitSelectableAssetListOut(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.enums.private_portrait import (
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitProjectStatus,
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
)
|
||||
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject
|
||||
from app.models.user import User
|
||||
from app.schemas.private_portrait import (
|
||||
PrivatePortraitAssetCreate,
|
||||
PrivatePortraitAssetListOut,
|
||||
PrivatePortraitAssetOut,
|
||||
PrivatePortraitConfigOut,
|
||||
PrivatePortraitDeleteOut,
|
||||
PrivatePortraitEnumMetaOut,
|
||||
PrivatePortraitProjectListOut,
|
||||
PrivatePortraitProjectOut,
|
||||
PrivatePortraitProjectUpdate,
|
||||
PrivatePortraitSelectableAssetListOut,
|
||||
PrivatePortraitVirtualProjectCreate,
|
||||
build_private_portrait_enum_meta,
|
||||
)
|
||||
from app.services.operation_log_service import log_operation_error, log_operation_event
|
||||
from app.services.private_portrait.asset_service import (
|
||||
DOMAIN,
|
||||
asset_to_out,
|
||||
get_user_private_portrait_config,
|
||||
list_assets,
|
||||
list_selectable_assets,
|
||||
soft_delete_asset,
|
||||
sync_asset_status,
|
||||
)
|
||||
from app.services.private_portrait.project_service import (
|
||||
get_user_project,
|
||||
list_projects,
|
||||
project_to_out,
|
||||
refresh_project_counters,
|
||||
soft_delete_project,
|
||||
)
|
||||
from app.services.private_portrait.virtual.service import create_virtual_asset, create_virtual_project, update_virtual_project
|
||||
|
||||
router = APIRouter(tags=["私域虚拟人像素材库"])
|
||||
|
||||
|
||||
def _log_task_dispatch_failed(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None, exc: BaseException) -> None:
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.TASK_DISPATCH_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, asset_id=asset_id, exc=exc, detail={"task_name": task_name})
|
||||
|
||||
|
||||
def _log_task_dispatch_success(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None) -> None:
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.TASK_DISPATCH_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, asset_id=asset_id, detail={"task_name": task_name})
|
||||
|
||||
|
||||
@router.get("/private-portrait/virtual/config", response_model=PrivatePortraitConfigOut, summary="获取虚拟人像素材库额度配置", description="额度与真人认证素材库共用;图片/视频共用;Audio 暂不开放。")
|
||||
async def get_my_virtual_private_portrait_config(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
return await get_user_private_portrait_config(db, user_id=current_user.id)
|
||||
|
||||
|
||||
@router.get("/private-portrait/virtual-meta/enums", response_model=PrivatePortraitEnumMetaOut, summary="获取虚拟人像素材库枚举说明")
|
||||
async def get_virtual_private_portrait_enum_meta():
|
||||
return build_private_portrait_enum_meta()
|
||||
|
||||
|
||||
@router.post("/private-portrait/virtual-projects", response_model=PrivatePortraitProjectOut, summary="创建虚拟人像项目组", description="创建本地虚拟人像项目,并同步调用火山 CreateAssetGroup,GroupType=AIGC。ProjectName 必须与后续生成 API Key 所属项目一致,默认使用 default。")
|
||||
async def create_private_portrait_virtual_project(payload: PrivatePortraitVirtualProjectCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
project = await create_virtual_project(db, user_id=current_user.id, payload=payload)
|
||||
out = project_to_out(project)
|
||||
await db.commit()
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/private-portrait/virtual-projects", response_model=PrivatePortraitProjectListOut, summary="查询当前用户虚拟人像项目列表")
|
||||
async def list_private_portrait_virtual_projects(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), keyword: str | None = Query(None), status: str | None = Query(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
query_status = status or PrivatePortraitProjectStatus.ACTIVE.value
|
||||
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=query_status, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
|
||||
await refresh_project_counters(db, [item.id for item in items])
|
||||
await db.commit()
|
||||
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=query_status, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
|
||||
return PrivatePortraitProjectListOut(items=[project_to_out(item) for item in items], total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/private-portrait/virtual-projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="获取虚拟人像项目详情")
|
||||
async def get_private_portrait_virtual_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
return project_to_out(await get_user_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value))
|
||||
|
||||
|
||||
@router.put("/private-portrait/virtual-projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="更新虚拟人像项目")
|
||||
async def update_private_portrait_virtual_project(project_id: str, payload: PrivatePortraitProjectUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
project = await update_virtual_project(db, user_id=current_user.id, project_id=project_id, payload=payload)
|
||||
out = project_to_out(project)
|
||||
await db.commit()
|
||||
return out
|
||||
|
||||
|
||||
@router.delete("/private-portrait/virtual-projects/{project_id}", response_model=PrivatePortraitDeleteOut, summary="删除虚拟人像项目")
|
||||
async def delete_private_portrait_virtual_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
project = await soft_delete_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
|
||||
project_id_snapshot = project.id
|
||||
await db.commit()
|
||||
try:
|
||||
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_project_remote
|
||||
|
||||
delete_private_portrait_project_remote.delay(project_id_snapshot)
|
||||
_log_task_dispatch_success(task_name="private_portrait.delete_project_remote", user_id=current_user.id, project_id=project_id_snapshot)
|
||||
except Exception as exc:
|
||||
_log_task_dispatch_failed(task_name="private_portrait.delete_project_remote", user_id=current_user.id, project_id=project_id_snapshot, exc=exc)
|
||||
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
|
||||
|
||||
|
||||
@router.post("/private-portrait/virtual-projects/{project_id}/assets", response_model=PrivatePortraitAssetOut, summary="上传虚拟人像素材", description="当前支持 Image / Video。Audio 暂不开放。CreateAsset 是异步接口,返回后需要轮询到 Active 才可用于生成。")
|
||||
async def create_private_portrait_virtual_asset(project_id: str, payload: PrivatePortraitAssetCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
asset = await create_virtual_asset(db, user_id=current_user.id, project_id=project_id, payload=payload)
|
||||
asset_id_snapshot = asset.id
|
||||
project_id_snapshot = asset.project_id
|
||||
out = asset_to_out(asset)
|
||||
await db.commit()
|
||||
try:
|
||||
from app.tasks.private_portrait_asset_tasks import poll_private_portrait_asset_status
|
||||
|
||||
poll_private_portrait_asset_status.delay(asset_id_snapshot)
|
||||
_log_task_dispatch_success(task_name="private_portrait.poll_asset_status", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot)
|
||||
except Exception as exc:
|
||||
_log_task_dispatch_failed(task_name="private_portrait.poll_asset_status", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot, exc=exc)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/private-portrait/virtual-projects/{project_id}/assets", response_model=PrivatePortraitAssetListOut, summary="查询虚拟人像素材列表")
|
||||
async def list_private_portrait_virtual_assets(project_id: str, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status: str | None = Query(None), keyword: str | None = Query(None), asset_type: str | None = Query(None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
assets, total, project_name_map = await list_assets(db, user_id=current_user.id, project_id=project_id, status=status, keyword=keyword, page=page, page_size=page_size, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, asset_type=asset_type)
|
||||
return PrivatePortraitAssetListOut(items=[asset_to_out(asset, project_name=project_name_map.get(asset.project_id)) for asset in assets], total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.get("/private-portrait/virtual-assets/{asset_id}", response_model=PrivatePortraitAssetOut, summary="获取虚拟人像素材详情")
|
||||
async def get_private_portrait_virtual_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == current_user.id, PrivatePortraitAsset.library_type == PrivatePortraitLibraryType.AIGC_VIRTUAL.value).limit(1))).scalar_one_or_none()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="虚拟人像素材不存在")
|
||||
project = (await db.execute(select(PrivatePortraitProject).where(PrivatePortraitProject.id == asset.project_id).limit(1))).scalar_one_or_none()
|
||||
return asset_to_out(asset, project_name=project.name if project else None)
|
||||
|
||||
|
||||
@router.post("/private-portrait/virtual-assets/{asset_id}/sync", response_model=PrivatePortraitAssetOut, summary="同步虚拟人像素材状态")
|
||||
async def sync_private_portrait_virtual_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
asset = await sync_asset_status(db, user_id=current_user.id, asset_id=asset_id)
|
||||
if asset.library_type != PrivatePortraitLibraryType.AIGC_VIRTUAL.value:
|
||||
raise HTTPException(status_code=404, detail="虚拟人像素材不存在")
|
||||
out = asset_to_out(asset)
|
||||
await db.commit()
|
||||
return out
|
||||
|
||||
|
||||
@router.delete("/private-portrait/virtual-assets/{asset_id}", response_model=PrivatePortraitDeleteOut, summary="删除虚拟人像素材")
|
||||
async def delete_private_portrait_virtual_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
asset = await soft_delete_asset(db, user_id=current_user.id, asset_id=asset_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
|
||||
asset_id_snapshot = asset.id
|
||||
project_id_snapshot = asset.project_id
|
||||
await db.commit()
|
||||
try:
|
||||
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_asset_remote
|
||||
|
||||
delete_private_portrait_asset_remote.delay(asset_id_snapshot)
|
||||
_log_task_dispatch_success(task_name="private_portrait.delete_asset_remote", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot)
|
||||
except Exception as exc:
|
||||
_log_task_dispatch_failed(task_name="private_portrait.delete_asset_remote", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot, exc=exc)
|
||||
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
|
||||
|
||||
|
||||
@router.get("/private-portrait/virtual-selectable-assets", response_model=PrivatePortraitSelectableAssetListOut, summary="查询可用于生成的虚拟人像素材")
|
||||
async def list_private_portrait_virtual_selectable_assets(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), project_id: str | None = Query(None), keyword: str | None = Query(None), asset_type: str | None = Query(None, description="Image 或 Video,不传查全部。"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
items, total = await list_selectable_assets(db, user_id=current_user.id, project_id=project_id, keyword=keyword, page=page, page_size=page_size, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, asset_type=asset_type)
|
||||
return PrivatePortraitSelectableAssetListOut(items=items, total=total, page=page, page_size=page_size)
|
||||
@@ -37,6 +37,10 @@ class Settings(BaseSettings):
|
||||
SMS_TEMPLATE_CODE: str = "SMS_001"
|
||||
SMS_MOCK: bool = True
|
||||
|
||||
# 火山引擎配置。
|
||||
VOLC_ACCESS_KEY_ID: str = ""
|
||||
VOLC_SECRET_ACCESS_KEY: str = ""
|
||||
|
||||
# 火山引擎短信配置。
|
||||
# SmsAccount=消息组ID,TemplateID=模板ID,Sign=短信签名内容。
|
||||
VOLC_SMS_ACCESS_KEY_ID: str = ""
|
||||
|
||||
@@ -2,14 +2,18 @@ from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
# 用户真人素材图片默认上限。users.private_portrait_image_limit = 0 表示关闭模块;>0 表示启用并限制总量。
|
||||
PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT = 5
|
||||
# 用户私域人像素材默认上限。users.private_portrait_asset_limit = 0 表示关闭模块;>0 表示启用并限制总素材量。
|
||||
# 统计口径:真人 + 虚拟;图片 + 视频。音频当前业务暂不开放。
|
||||
PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT = 5
|
||||
|
||||
# 火山 Ark 私域真人素材 ProjectName:火山侧项目空间固定使用 default,并快照到各业务表 remote_project_name。
|
||||
# 火山 Ark 私域素材 ProjectName:火山侧项目空间固定使用 default,并快照到各业务表 remote_project_name。
|
||||
# 用户/项目隔离依赖本地 project_id 和火山返回的 Asset Group ID,不再动态拼接 ProjectName。
|
||||
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME = "default"
|
||||
|
||||
PRIVATE_PORTRAIT_GROUP_TYPE = "LivenessFace"
|
||||
PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE = "LivenessFace"
|
||||
PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE = "AIGC"
|
||||
# 兼容旧代码导入,默认代表真人认证素材组类型。
|
||||
PRIVATE_PORTRAIT_GROUP_TYPE = PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE
|
||||
PRIVATE_PORTRAIT_VERIFY_TYPE = "real_time"
|
||||
PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE = "10000"
|
||||
PRIVATE_PORTRAIT_ASSET_URI_PREFIX = "asset://"
|
||||
@@ -20,7 +24,9 @@ ARK_PRIVATE_PORTRAIT_REGION = "cn-beijing"
|
||||
ARK_PRIVATE_PORTRAIT_HOST = "ark.cn-beijing.volcengineapi.com"
|
||||
|
||||
PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS = 20
|
||||
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS = 30
|
||||
PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT = 60
|
||||
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT = 120
|
||||
PRIVATE_PORTRAIT_ASSET_POLL_BATCH_SIZE = 50
|
||||
PRIVATE_PORTRAIT_REMOTE_DELETE_RECOVERY_BATCH_SIZE = 50
|
||||
PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES = 30
|
||||
@@ -29,6 +35,7 @@ PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES = 30
|
||||
PRIVATE_PORTRAIT_ACTION_QPS_LIMITS: dict[str, int] = {
|
||||
"CreateVisualValidateSession": 3,
|
||||
"GetVisualValidateResult": 3,
|
||||
"CreateAssetGroup": 10,
|
||||
"CreateAsset": 1,
|
||||
"ListAssetGroups": 10,
|
||||
"ListAssets": 10,
|
||||
@@ -44,6 +51,7 @@ PRIVATE_PORTRAIT_ACTION_QPS_LIMITS: dict[str, int] = {
|
||||
class ArkPrivatePortraitAction(str, Enum):
|
||||
CREATE_VISUAL_VALIDATE_SESSION = "CreateVisualValidateSession"
|
||||
GET_VISUAL_VALIDATE_RESULT = "GetVisualValidateResult"
|
||||
CREATE_ASSET_GROUP = "CreateAssetGroup"
|
||||
CREATE_ASSET = "CreateAsset"
|
||||
GET_ASSET = "GetAsset"
|
||||
LIST_ASSETS = "ListAssets"
|
||||
@@ -55,10 +63,17 @@ class ArkPrivatePortraitAction(str, Enum):
|
||||
DELETE_ASSET_GROUP = "DeleteAssetGroup"
|
||||
|
||||
|
||||
class PrivatePortraitLibraryType(str, Enum):
|
||||
REAL_PERSON = "real_person"
|
||||
AIGC_VIRTUAL = "aigc_virtual"
|
||||
|
||||
|
||||
class PrivatePortraitProjectStatus(str, Enum):
|
||||
VALIDATING = "validating"
|
||||
ACTIVE = "active"
|
||||
VALIDATE_FAILED = "validate_failed"
|
||||
CREATING_REMOTE_GROUP = "creating_remote_group"
|
||||
CREATE_GROUP_FAILED = "create_group_failed"
|
||||
DELETED = "deleted"
|
||||
|
||||
|
||||
@@ -72,6 +87,7 @@ class PrivatePortraitValidateSessionStatus(str, Enum):
|
||||
|
||||
|
||||
class PrivatePortraitAssetGroupStatus(str, Enum):
|
||||
CREATING = "creating"
|
||||
ACTIVE = "active"
|
||||
LOCAL_DELETED = "local_deleted"
|
||||
REMOTE_DELETED = "remote_deleted"
|
||||
@@ -92,7 +108,13 @@ class PrivatePortraitAssetStatus(str, Enum):
|
||||
class PrivatePortraitAssetType(str, Enum):
|
||||
IMAGE = "Image"
|
||||
VIDEO = "Video"
|
||||
AUDIO = "Audio"
|
||||
AUDIO = "Audio" # 火山支持,但当前业务暂不开放。
|
||||
|
||||
|
||||
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES = {
|
||||
PrivatePortraitAssetType.IMAGE.value,
|
||||
PrivatePortraitAssetType.VIDEO.value,
|
||||
}
|
||||
|
||||
|
||||
class PrivatePortraitRemoteDeleteStatus(str, Enum):
|
||||
@@ -125,9 +147,19 @@ class PrivatePortraitEventSource(str, Enum):
|
||||
|
||||
|
||||
class PrivatePortraitEventType(str, Enum):
|
||||
QUOTA_CHECK_START = "QUOTA_CHECK_START"
|
||||
QUOTA_CHECK_PASS = "QUOTA_CHECK_PASS"
|
||||
QUOTA_CHECK_DENY = "QUOTA_CHECK_DENY"
|
||||
|
||||
PROJECT_CREATE = "PROJECT_CREATE"
|
||||
PROJECT_UPDATE = "PROJECT_UPDATE"
|
||||
PROJECT_DELETE = "PROJECT_DELETE"
|
||||
|
||||
VIRTUAL_PROJECT_CREATE_START = "VIRTUAL_PROJECT_CREATE_START"
|
||||
VIRTUAL_ASSET_GROUP_CREATE_REMOTE_START = "VIRTUAL_ASSET_GROUP_CREATE_REMOTE_START"
|
||||
VIRTUAL_ASSET_GROUP_CREATE_REMOTE_SUCCESS = "VIRTUAL_ASSET_GROUP_CREATE_REMOTE_SUCCESS"
|
||||
VIRTUAL_ASSET_GROUP_CREATE_REMOTE_FAILED = "VIRTUAL_ASSET_GROUP_CREATE_REMOTE_FAILED"
|
||||
|
||||
VALIDATE_SESSION_CREATE = "VALIDATE_SESSION_CREATE"
|
||||
VALIDATE_SESSION_CREATE_FAILED = "VALIDATE_SESSION_CREATE_FAILED"
|
||||
VALIDATE_CALLBACK_RECEIVED = "VALIDATE_CALLBACK_RECEIVED"
|
||||
@@ -163,7 +195,6 @@ class PrivatePortraitEventType(str, Enum):
|
||||
PROJECT_DELETE_REMOTE_SUCCESS = "PROJECT_DELETE_REMOTE_SUCCESS"
|
||||
PROJECT_DELETE_REMOTE_FAILED = "PROJECT_DELETE_REMOTE_FAILED"
|
||||
|
||||
|
||||
TASK_DISPATCH_SUCCESS = "TASK_DISPATCH_SUCCESS"
|
||||
TASK_DISPATCH_FAILED = "TASK_DISPATCH_FAILED"
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.enums.private_portrait import (
|
||||
PrivatePortraitAssetStatus,
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
)
|
||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||
@@ -19,10 +20,13 @@ class PrivatePortraitAsset(Base, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "private_portrait_assets"
|
||||
__table_args__ = (
|
||||
Index("uq_private_portrait_assets_remote_asset_id", "remote_asset_id", unique=True),
|
||||
Index("idx_private_portrait_assets_user_library_status_created", "user_id", "library_type", "status", "created_at"),
|
||||
Index("idx_private_portrait_assets_user_status_created", "user_id", "status", "created_at"),
|
||||
Index("idx_private_portrait_assets_project_status_created", "project_id", "status", "created_at"),
|
||||
Index("idx_private_portrait_assets_group_status_created", "group_id", "status", "created_at"),
|
||||
Index("idx_private_portrait_assets_remote_project_name", "remote_project_name"),
|
||||
Index("idx_private_portrait_assets_library_type", "library_type"),
|
||||
Index("idx_private_portrait_assets_asset_type", "asset_type"),
|
||||
Index(
|
||||
"idx_private_portrait_assets_next_poll_status",
|
||||
"next_poll_at",
|
||||
@@ -36,6 +40,13 @@ class PrivatePortraitAsset(Base, TimestampMixin, SoftDeleteMixin):
|
||||
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(32), ForeignKey("private_portrait_projects.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
group_id: Mapped[str] = mapped_column(String(32), ForeignKey("private_portrait_asset_groups.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
library_type: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
default=PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
server_default=PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
index=True,
|
||||
)
|
||||
remote_group_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
remote_asset_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
remote_project_name: Mapped[str] = mapped_column(String(256), nullable=False, index=True)
|
||||
@@ -45,6 +56,10 @@ class PrivatePortraitAsset(Base, TimestampMixin, SoftDeleteMixin):
|
||||
preview_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
remote_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
remote_url_expired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
video_duration: Mapped[float | None] = mapped_column(Float, nullable=True, comment="视频素材时长,秒")
|
||||
video_cover_url: Mapped[str | None] = mapped_column(Text, nullable=True, comment="视频素材封面预览地址")
|
||||
file_size: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="素材文件大小,字节")
|
||||
mime_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
|
||||
@@ -6,8 +6,9 @@ from sqlalchemy import DateTime, ForeignKey, Index, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_GROUP_TYPE,
|
||||
PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE,
|
||||
PrivatePortraitAssetGroupStatus,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
)
|
||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||
@@ -20,9 +21,11 @@ class PrivatePortraitAssetGroup(Base, TimestampMixin, SoftDeleteMixin):
|
||||
__table_args__ = (
|
||||
Index("uq_private_portrait_asset_groups_remote_group_id", "remote_group_id", unique=True),
|
||||
Index("idx_private_portrait_asset_groups_user_project", "user_id", "project_id"),
|
||||
Index("idx_private_portrait_asset_groups_user_library", "user_id", "library_type"),
|
||||
Index("idx_private_portrait_asset_groups_project_status", "project_id", "status"),
|
||||
Index("idx_private_portrait_asset_groups_remote_delete_status", "remote_delete_status"),
|
||||
Index("idx_private_portrait_asset_groups_remote_project_name", "remote_project_name"),
|
||||
Index("idx_private_portrait_asset_groups_library_type", "library_type"),
|
||||
Index(
|
||||
"uq_private_portrait_asset_groups_one_active_project",
|
||||
"project_id",
|
||||
@@ -34,10 +37,17 @@ class PrivatePortraitAssetGroup(Base, TimestampMixin, SoftDeleteMixin):
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(32), ForeignKey("private_portrait_projects.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
library_type: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
default=PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
server_default=PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
index=True,
|
||||
)
|
||||
remote_group_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
remote_group_name: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
remote_project_name: Mapped[str] = mapped_column(String(256), nullable=False, index=True)
|
||||
group_type: Mapped[str] = mapped_column(String(32), nullable=False, default=PRIVATE_PORTRAIT_GROUP_TYPE, server_default=PRIVATE_PORTRAIT_GROUP_TYPE)
|
||||
group_type: Mapped[str] = mapped_column(String(32), nullable=False, default=PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE, server_default=PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
|
||||
@@ -5,15 +5,16 @@ from datetime import datetime
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.enums.private_portrait import PrivatePortraitProjectStatus
|
||||
from app.enums.private_portrait import PrivatePortraitLibraryType, PrivatePortraitProjectStatus
|
||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
|
||||
class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"""用户本地真人素材项目组。remote_project_name 是火山 ProjectName 快照。"""
|
||||
"""用户本地私域人像素材项目组。remote_project_name 是火山 ProjectName 快照。"""
|
||||
|
||||
__tablename__ = "private_portrait_projects"
|
||||
__table_args__ = (
|
||||
Index("idx_private_portrait_projects_user_library_status_created", "user_id", "library_type", "status", "created_at"),
|
||||
Index("idx_private_portrait_projects_user_status_created", "user_id", "status", "created_at"),
|
||||
Index(
|
||||
"idx_private_portrait_projects_user_deleted",
|
||||
@@ -22,10 +23,19 @@ class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin):
|
||||
postgresql_where=text("deleted_at IS NULL"),
|
||||
),
|
||||
Index("idx_private_portrait_projects_remote_project_name", "remote_project_name"),
|
||||
Index("idx_private_portrait_projects_library_type", "library_type"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
library_type: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
default=PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
server_default=PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
index=True,
|
||||
comment="素材库类型:real_person 真人认证;aigc_virtual 虚拟人像",
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="用户展示项目名")
|
||||
name_slug: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="项目名安全 slug")
|
||||
remote_project_name: Mapped[str] = mapped_column(String(256), nullable=False, index=True, comment="火山 ProjectName 快照")
|
||||
@@ -39,5 +49,9 @@ class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin):
|
||||
)
|
||||
asset_group_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
image_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
video_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
active_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
active_image_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
active_video_asset_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@@ -39,8 +39,8 @@ class User(Base, TimestampMixin):
|
||||
)
|
||||
allowed_menus: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
# 真人素材库图片总量限制。0 表示关闭真人素材模块;>0 表示启用并限制用户所有真人素材图片总量。
|
||||
private_portrait_image_limit: Mapped[int] = mapped_column(
|
||||
# 私域人像素材总量限制。0 表示关闭模块;>0 表示启用并限制真人/虚拟、图片/视频素材总量。
|
||||
private_portrait_asset_limit: Mapped[int] = mapped_column(
|
||||
Integer, default=5, server_default="5", nullable=False
|
||||
)
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ class AdminUserOut(BaseModel):
|
||||
last_login_at: NaiveDatetimeOptional = None
|
||||
allowed_menus: list | None = None
|
||||
resource_capacity: ResourceCapacityUsageOut | None = None
|
||||
private_portrait_image_limit: int = 5
|
||||
private_portrait_asset_limit: int = 5
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -72,7 +72,7 @@ class CreateUserRequest(BaseModel):
|
||||
user_type: str = Field(default="frontend", pattern="^(frontend|admin)$")
|
||||
frontend_user_kind: str = Field(default="external", pattern="^(internal|external)$")
|
||||
allowed_menus: list | None = None
|
||||
private_portrait_image_limit: int = Field(default=5, ge=0, le=9999)
|
||||
private_portrait_asset_limit: int = Field(default=5, ge=0, le=9999, description="私域人像素材总量限制,真人/虚拟、图片/视频共用,0 表示关闭")
|
||||
|
||||
|
||||
class UpdateFrontendUserKindRequest(BaseModel):
|
||||
|
||||
@@ -4,36 +4,67 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.enums.private_portrait import PrivatePortraitAssetType
|
||||
from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
|
||||
PrivatePortraitAssetStatus,
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitProjectStatus,
|
||||
)
|
||||
from app.schemas.common import NaiveDatetimeOptional
|
||||
|
||||
|
||||
class PrivatePortraitEnumItem(BaseModel):
|
||||
value: str
|
||||
label: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class PrivatePortraitEnumMetaOut(BaseModel):
|
||||
library_types: list[PrivatePortraitEnumItem]
|
||||
asset_types: list[PrivatePortraitEnumItem]
|
||||
project_statuses: list[PrivatePortraitEnumItem]
|
||||
asset_statuses: list[PrivatePortraitEnumItem]
|
||||
|
||||
|
||||
class PrivatePortraitConfigOut(BaseModel):
|
||||
enabled: bool
|
||||
image_limit: int
|
||||
used_image_count: int
|
||||
remaining_image_count: int
|
||||
enabled: bool = Field(..., description="是否启用私域人像素材库。asset_limit > 0 表示启用。")
|
||||
asset_limit: int = Field(..., description="私域人像素材总量限制:真人/虚拟共用,图片/视频共用,0 表示关闭。")
|
||||
used_asset_count: int = Field(..., description="当前占用额度的素材数量。统计 creating/Processing/Active 的 Image/Video。")
|
||||
remaining_asset_count: int = Field(..., description="剩余可上传素材数量。")
|
||||
supported_asset_types: list[str] = Field(default_factory=lambda: [PrivatePortraitAssetType.IMAGE.value, PrivatePortraitAssetType.VIDEO.value], description="当前业务开放的素材类型。")
|
||||
unsupported_asset_types: list[str] = Field(default_factory=lambda: [PrivatePortraitAssetType.AUDIO.value], description="火山支持但当前业务暂不开放的素材类型。")
|
||||
# 兼容旧前端,后续确认无引用后可移除。
|
||||
image_limit: int | None = Field(None, description="兼容旧字段:请改用 asset_limit。")
|
||||
used_image_count: int | None = Field(None, description="兼容旧字段:请改用 used_asset_count。")
|
||||
remaining_image_count: int | None = Field(None, description="兼容旧字段:请改用 remaining_asset_count。")
|
||||
|
||||
|
||||
class PrivatePortraitAdminConfigUpdate(BaseModel):
|
||||
private_portrait_image_limit: int = Field(..., ge=0, le=9999, description="0 表示关闭真人素材模块;>0 表示启用并限制图片总量")
|
||||
private_portrait_asset_limit: int = Field(..., ge=0, le=9999, description="私域人像素材总量限制。0 表示关闭;>0 表示启用并限制真人/虚拟、图片/视频素材总量。")
|
||||
|
||||
|
||||
class PrivatePortraitProjectCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=128)
|
||||
description: str | None = Field(None, max_length=2000)
|
||||
callback_redirect_url: str | None = Field(None, description="项目创建时真人认证完成后的手机端提示页地址")
|
||||
name: str = Field(..., min_length=1, max_length=128, description="项目组名称。")
|
||||
description: str | None = Field(None, max_length=2000, description="项目组描述。")
|
||||
callback_redirect_url: str | None = Field(None, description="仅真人认证使用:认证完成后的手机端提示页地址。")
|
||||
|
||||
|
||||
class PrivatePortraitVirtualProjectCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=128, description="虚拟人像素材项目组名称。创建后会同步创建火山 Asset Group。")
|
||||
description: str | None = Field(None, max_length=2000, description="虚拟人像素材项目组描述,会同步到火山 Asset Group。")
|
||||
|
||||
|
||||
class PrivatePortraitProjectUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=128)
|
||||
description: str | None = Field(None, max_length=2000)
|
||||
status: str | None = None
|
||||
name: str | None = Field(None, min_length=1, max_length=128, description="项目组名称。")
|
||||
description: str | None = Field(None, max_length=2000, description="项目组描述。")
|
||||
status: str | None = Field(None, description="项目状态。普通前端不建议手动变更,仅管理/排查使用。")
|
||||
|
||||
|
||||
class PrivatePortraitProjectOut(BaseModel):
|
||||
id: str
|
||||
user_id: str | None = None
|
||||
library_type: str = Field(default=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
name: str
|
||||
name_slug: str | None = None
|
||||
remote_project_name: str | None = None
|
||||
@@ -41,7 +72,11 @@ class PrivatePortraitProjectOut(BaseModel):
|
||||
status: str
|
||||
asset_group_count: int = 0
|
||||
asset_count: int = 0
|
||||
image_asset_count: int = 0
|
||||
video_asset_count: int = 0
|
||||
active_asset_count: int = 0
|
||||
active_image_asset_count: int = 0
|
||||
active_video_asset_count: int = 0
|
||||
last_used_at: NaiveDatetimeOptional = None
|
||||
created_at: NaiveDatetimeOptional = None
|
||||
updated_at: NaiveDatetimeOptional = None
|
||||
@@ -81,18 +116,17 @@ class PrivatePortraitValidateSessionOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
|
||||
|
||||
class PrivatePortraitProjectCreateWithValidateOut(BaseModel):
|
||||
project: PrivatePortraitProjectOut
|
||||
validate_session: PrivatePortraitValidateSessionOut
|
||||
poll_interval_ms: int = Field(default=2000, description="PC 端轮询认证状态的建议间隔,单位毫秒")
|
||||
poll_interval_ms: int = Field(default=2000, description="PC 端轮询认证状态的建议间隔,单位毫秒。")
|
||||
|
||||
|
||||
class PrivatePortraitAssetGroupOut(BaseModel):
|
||||
id: str
|
||||
user_id: str | None = None
|
||||
project_id: str
|
||||
library_type: str
|
||||
remote_group_id: str
|
||||
remote_group_name: str | None = None
|
||||
remote_project_name: str
|
||||
@@ -108,16 +142,22 @@ class PrivatePortraitAssetGroupOut(BaseModel):
|
||||
|
||||
|
||||
class PrivatePortraitAssetCreate(BaseModel):
|
||||
url: str = Field(..., min_length=1, description="已上传到本系统且可公网访问的素材 URL")
|
||||
asset_type: str = Field(default=PrivatePortraitAssetType.IMAGE.value)
|
||||
name: str | None = Field(None, max_length=256)
|
||||
url: str = Field(..., min_length=1, description="已上传到本系统且可公网访问的素材 URL。支持图片/视频,后端会转换公网地址后调用火山 CreateAsset。")
|
||||
asset_type: str = Field(default=PrivatePortraitAssetType.IMAGE.value, description="素材类型。当前业务仅开放 Image / Video,Audio 暂不开放。")
|
||||
name: str | None = Field(None, max_length=256, description="素材名称,仅用于检索和管理。")
|
||||
video_duration: float | None = Field(None, ge=0, description="视频素材时长,单位秒。图片可为空。")
|
||||
video_cover_url: str | None = Field(None, description="视频封面预览地址。图片可为空。")
|
||||
file_size: int | None = Field(None, ge=0, description="文件大小,字节。")
|
||||
mime_type: str | None = Field(None, max_length=128, description="素材 MIME 类型。")
|
||||
|
||||
@field_validator("asset_type")
|
||||
@classmethod
|
||||
def validate_asset_type(cls, v: str) -> str:
|
||||
value = v or PrivatePortraitAssetType.IMAGE.value
|
||||
if value not in {item.value for item in PrivatePortraitAssetType}:
|
||||
raise ValueError("asset_type 仅支持 Image/Video/Audio")
|
||||
raise ValueError("asset_type 仅支持 Image/Video,Audio 暂未开放")
|
||||
if value not in PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES:
|
||||
raise ValueError("Audio 暂未开放,当前仅支持 Image/Video")
|
||||
return value
|
||||
|
||||
|
||||
@@ -127,6 +167,7 @@ class PrivatePortraitAssetOut(BaseModel):
|
||||
project_id: str
|
||||
project_name: str | None = None
|
||||
group_id: str
|
||||
library_type: str
|
||||
remote_group_id: str
|
||||
remote_asset_id: str | None = None
|
||||
remote_project_name: str | None = None
|
||||
@@ -134,10 +175,16 @@ class PrivatePortraitAssetOut(BaseModel):
|
||||
name: str | None = None
|
||||
source_url: str
|
||||
preview_url: str | None = None
|
||||
display_url: str | None = None
|
||||
provider_url: str | None = None
|
||||
remote_url: str | None = None
|
||||
remote_url_expired_at: NaiveDatetimeOptional = None
|
||||
video_duration: float | None = None
|
||||
video_cover_url: str | None = None
|
||||
file_size: int | None = None
|
||||
mime_type: str | None = None
|
||||
status: str
|
||||
moderation: dict[str, Any] | None = None
|
||||
moderation: Any = None
|
||||
last_poll_at: NaiveDatetimeOptional = None
|
||||
next_poll_at: NaiveDatetimeOptional = None
|
||||
poll_count: int = 0
|
||||
@@ -162,10 +209,15 @@ class PrivatePortraitSelectableAssetOut(BaseModel):
|
||||
id: str
|
||||
project_id: str
|
||||
project_name: str
|
||||
library_type: str
|
||||
name: str | None = None
|
||||
asset_type: str
|
||||
preview_url: str | None = None
|
||||
status: str
|
||||
display_url: str | None = None
|
||||
provider_url: str | None = None
|
||||
video_duration: float | None = None
|
||||
video_cover_url: str | None = None
|
||||
status: str = PrivatePortraitAssetStatus.ACTIVE.value
|
||||
created_at: NaiveDatetimeOptional = None
|
||||
|
||||
|
||||
@@ -178,4 +230,32 @@ class PrivatePortraitSelectableAssetListOut(BaseModel):
|
||||
|
||||
class PrivatePortraitDeleteOut(BaseModel):
|
||||
success: bool = True
|
||||
remote_delete_status: str | None = None
|
||||
remote_delete_status: str
|
||||
|
||||
|
||||
class PrivatePortraitAdminStatsOut(BaseModel):
|
||||
total_projects: int = 0
|
||||
total_assets: int = 0
|
||||
image_assets: int = 0
|
||||
video_assets: int = 0
|
||||
active_assets: int = 0
|
||||
processing_assets: int = 0
|
||||
failed_assets: int = 0
|
||||
real_person_assets: int = 0
|
||||
virtual_assets: int = 0
|
||||
|
||||
|
||||
def build_private_portrait_enum_meta() -> PrivatePortraitEnumMetaOut:
|
||||
return PrivatePortraitEnumMetaOut(
|
||||
library_types=[
|
||||
PrivatePortraitEnumItem(value=PrivatePortraitLibraryType.REAL_PERSON.value, label="真人认证素材库", description="需要用户扫码完成真人授权认证后才能上传素材。"),
|
||||
PrivatePortraitEnumItem(value=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, label="私域虚拟人像素材库", description="通过火山 CreateAssetGroup/CreateAsset 入库的虚拟人像素材。"),
|
||||
],
|
||||
asset_types=[
|
||||
PrivatePortraitEnumItem(value=PrivatePortraitAssetType.IMAGE.value, label="图片", description="当前开放。"),
|
||||
PrivatePortraitEnumItem(value=PrivatePortraitAssetType.VIDEO.value, label="视频", description="当前开放,处理时间通常比图片更长。"),
|
||||
PrivatePortraitEnumItem(value=PrivatePortraitAssetType.AUDIO.value, label="音频", description="火山支持但当前业务暂不开放。"),
|
||||
],
|
||||
project_statuses=[PrivatePortraitEnumItem(value=item.value, label=item.value) for item in PrivatePortraitProjectStatus],
|
||||
asset_statuses=[PrivatePortraitEnumItem(value=item.value, label=item.value) for item in PrivatePortraitAssetStatus],
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ class UserOut(BaseModel):
|
||||
allowed_menus: list | None = None
|
||||
must_set_password: bool = False
|
||||
resource_capacity: ResourceCapacityUsageOut | None = None
|
||||
private_portrait_image_limit: int = 5
|
||||
private_portrait_asset_limit: int = 5
|
||||
team_id: str | None = None
|
||||
team_name: str | None = None
|
||||
is_team_manager: bool = False
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.private_portrait.asset_service import asset_to_out, list_assets
|
||||
|
||||
|
||||
async def admin_list_assets(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
project_id: str | None = None,
|
||||
library_type: str | None = None,
|
||||
asset_type: str | None = None,
|
||||
keyword: str | None = None,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
):
|
||||
assets, total, project_name_map = await list_assets(db, user_id=user_id, project_id=project_id, status=status, keyword=keyword, page=page, page_size=page_size, library_type=library_type, asset_type=asset_type)
|
||||
return [asset_to_out(asset, project_name=project_name_map.get(asset.project_id), include_user=True) for asset in assets], total
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.private_portrait import PrivatePortraitProject
|
||||
from app.services.private_portrait.project_service import list_projects, project_to_out, refresh_project_counters
|
||||
|
||||
|
||||
async def _reload_projects_by_ids(db: AsyncSession, project_ids: list[str]) -> list[PrivatePortraitProject]:
|
||||
"""Reload projects after counter refresh to avoid async expired-attribute lazy load.
|
||||
|
||||
refresh_project_counters() uses bulk UPDATE. In SQLAlchemy async ORM, previously
|
||||
loaded ORM instances may become expired after a bulk update. Accessing expired
|
||||
scalar attributes outside greenlet context triggers MissingGreenlet. Reloading
|
||||
with populate_existing=True refreshes the identity-map objects during the awaited
|
||||
execute call, and keeps the original pagination order.
|
||||
"""
|
||||
if not project_ids:
|
||||
return []
|
||||
result = await db.execute(
|
||||
select(PrivatePortraitProject)
|
||||
.where(PrivatePortraitProject.id.in_(project_ids))
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
project_map = {project.id: project for project in result.scalars().all()}
|
||||
return [project_map[project_id] for project_id in project_ids if project_id in project_map]
|
||||
|
||||
|
||||
async def admin_list_projects(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
library_type: str | None = None,
|
||||
keyword: str | None = None,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
):
|
||||
items, total = await list_projects(
|
||||
db,
|
||||
user_id=user_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
keyword=keyword,
|
||||
status=status,
|
||||
library_type=library_type,
|
||||
)
|
||||
project_ids = [item.id for item in items]
|
||||
await refresh_project_counters(db, project_ids)
|
||||
|
||||
# Bulk UPDATE may expire loaded ORM instances. Re-query before DTO conversion.
|
||||
items = await _reload_projects_by_ids(db, project_ids)
|
||||
return [project_to_out(item, include_user=True) for item in items], total
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.private_portrait import PrivatePortraitAssetStatus, PrivatePortraitAssetType, PrivatePortraitLibraryType
|
||||
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject
|
||||
from app.schemas.private_portrait import PrivatePortraitAdminStatsOut
|
||||
|
||||
|
||||
async def admin_get_private_portrait_stats(db: AsyncSession, *, user_id: str | None = None, library_type: str | None = None) -> PrivatePortraitAdminStatsOut:
|
||||
project_filters = [PrivatePortraitProject.deleted_at.is_(None)]
|
||||
asset_filters = [PrivatePortraitAsset.deleted_at.is_(None)]
|
||||
if user_id:
|
||||
project_filters.append(PrivatePortraitProject.user_id == user_id)
|
||||
asset_filters.append(PrivatePortraitAsset.user_id == user_id)
|
||||
if library_type:
|
||||
project_filters.append(PrivatePortraitProject.library_type == library_type)
|
||||
asset_filters.append(PrivatePortraitAsset.library_type == library_type)
|
||||
|
||||
total_projects = (await db.execute(select(func.count(PrivatePortraitProject.id)).where(*project_filters))).scalar_one()
|
||||
rows = await db.execute(
|
||||
select(PrivatePortraitAsset.library_type, PrivatePortraitAsset.asset_type, PrivatePortraitAsset.status, func.count(PrivatePortraitAsset.id))
|
||||
.where(*asset_filters)
|
||||
.group_by(PrivatePortraitAsset.library_type, PrivatePortraitAsset.asset_type, PrivatePortraitAsset.status)
|
||||
)
|
||||
stats = PrivatePortraitAdminStatsOut(total_projects=int(total_projects or 0))
|
||||
for lib, asset_type, status, count in rows.all():
|
||||
n = int(count or 0)
|
||||
stats.total_assets += n
|
||||
if asset_type == PrivatePortraitAssetType.IMAGE.value:
|
||||
stats.image_assets += n
|
||||
elif asset_type == PrivatePortraitAssetType.VIDEO.value:
|
||||
stats.video_assets += n
|
||||
if status == PrivatePortraitAssetStatus.ACTIVE.value:
|
||||
stats.active_assets += n
|
||||
elif status == PrivatePortraitAssetStatus.PROCESSING.value:
|
||||
stats.processing_assets += n
|
||||
elif status == PrivatePortraitAssetStatus.FAILED.value:
|
||||
stats.failed_assets += n
|
||||
if lib == PrivatePortraitLibraryType.REAL_PERSON.value:
|
||||
stats.real_person_assets += n
|
||||
elif lib == PrivatePortraitLibraryType.AIGC_VIRTUAL.value:
|
||||
stats.virtual_assets += n
|
||||
return stats
|
||||
@@ -51,11 +51,11 @@ def _remote_error_http_status(code: str) -> int:
|
||||
|
||||
|
||||
class ArkPrivateAssetClient:
|
||||
"""火山 Ark 私域真人人像素材 API Client。只做 AK/SK 鉴权调用与响应标准化。"""
|
||||
"""火山 Ark 私域可信素材 Asset API Client。只做 AK/SK 鉴权调用与响应标准化。"""
|
||||
|
||||
def __init__(self, *, ak: str | None = None, sk: str | None = None, for_celery: bool = False):
|
||||
self.ak = ak or settings.VOLC_SMS_ACCESS_KEY_ID
|
||||
self.sk = sk or settings.VOLC_SMS_SECRET_ACCESS_KEY
|
||||
def __init__(self, *, for_celery: bool = False):
|
||||
self.ak = settings.VOLC_ACCESS_KEY_ID
|
||||
self.sk = settings.VOLC_SECRET_ACCESS_KEY
|
||||
self.for_celery = for_celery
|
||||
if not self.ak or not self.sk:
|
||||
raise ArkPrivateAssetClientError("火山 AK/SK 未配置:VOLC_SMS_ACCESS_KEY_ID / VOLC_SMS_SECRET_ACCESS_KEY")
|
||||
@@ -66,6 +66,12 @@ class ArkPrivateAssetClient:
|
||||
async def get_visual_validate_result(self, *, project_name: str, byted_token: str) -> dict[str, Any]:
|
||||
return await self._call(ArkPrivatePortraitAction.GET_VISUAL_VALIDATE_RESULT, {"BytedToken": byted_token, "ProjectName": project_name})
|
||||
|
||||
async def create_asset_group(self, *, project_name: str, name: str, description: str | None = None, group_type: str = "AIGC") -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"Name": name, "GroupType": group_type, "ProjectName": project_name}
|
||||
if description:
|
||||
payload["Description"] = description
|
||||
return await self._call(ArkPrivatePortraitAction.CREATE_ASSET_GROUP, payload)
|
||||
|
||||
async def create_asset(self, *, project_name: str, group_id: str, url: str, asset_type: str, name: str | None = None) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"GroupId": group_id, "URL": url, "AssetType": asset_type, "ProjectName": project_name}
|
||||
if name:
|
||||
|
||||
@@ -6,33 +6,42 @@ from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import and_, func, select, update
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS,
|
||||
PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT,
|
||||
PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT,
|
||||
PRIVATE_PORTRAIT_GROUP_TYPE,
|
||||
PRIVATE_PORTRAIT_ASSET_URI_PREFIX,
|
||||
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
|
||||
PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE,
|
||||
PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE,
|
||||
PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES,
|
||||
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS,
|
||||
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT,
|
||||
PrivatePortraitAssetGroupStatus,
|
||||
PrivatePortraitAssetStatus,
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitProjectStatus,
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
PrivatePortraitValidateSessionStatus,
|
||||
)
|
||||
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject, PrivatePortraitValidateSession
|
||||
from app.models.user import User
|
||||
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, PrivatePortraitConfigOut, PrivatePortraitSelectableAssetOut, PrivatePortraitValidateSessionOut
|
||||
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, PrivatePortraitSelectableAssetOut, PrivatePortraitValidateSessionOut
|
||||
from app.services.operation_log_service import log_operation_error, log_operation_event
|
||||
from app.services.private_portrait.ark_client import ArkPrivateAssetClient
|
||||
from app.services.private_portrait.project_service import get_user_project, refresh_project_counters
|
||||
from app.services.private_portrait.quota_service import (
|
||||
count_user_counting_assets,
|
||||
ensure_private_portrait_asset_quota_available,
|
||||
get_user_private_portrait_config,
|
||||
set_user_private_portrait_limit,
|
||||
)
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
DOMAIN = "private_portrait"
|
||||
@@ -53,7 +62,6 @@ def _loads(data: str | None) -> Any:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def _exception_message(exc: Exception) -> str:
|
||||
if isinstance(exc, HTTPException):
|
||||
detail = exc.detail
|
||||
@@ -65,7 +73,7 @@ def _exception_message(exc: Exception) -> str:
|
||||
|
||||
|
||||
def _public_url(url: str) -> str:
|
||||
if url.startswith(("http://", "https://")):
|
||||
if url.startswith(("http://", "https://", PRIVATE_PORTRAIT_ASSET_URI_PREFIX)):
|
||||
return url
|
||||
return f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}"
|
||||
|
||||
@@ -83,36 +91,31 @@ def _remote_group_name(user_id: str, project_name: str) -> str:
|
||||
return f"{user_id}-{safe_name}"[:128]
|
||||
|
||||
|
||||
async def get_user_private_portrait_config(db: AsyncSession, *, user_id: str) -> PrivatePortraitConfigOut:
|
||||
user = (await db.execute(select(User).where(User.id == user_id).limit(1))).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
limit = int(getattr(user, "private_portrait_image_limit", PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT) or 0)
|
||||
used = await count_user_counting_image_assets(db, user_id=user_id)
|
||||
return PrivatePortraitConfigOut(enabled=limit > 0, image_limit=limit, used_image_count=used, remaining_image_count=max(0, limit - used) if limit > 0 else 0)
|
||||
def _asset_display_url(asset: PrivatePortraitAsset) -> str | None:
|
||||
return asset.preview_url or asset.remote_url or asset.source_url or None
|
||||
|
||||
|
||||
async def set_user_private_portrait_limit(db: AsyncSession, *, user_id: str, limit: int) -> User:
|
||||
user = (await db.execute(select(User).where(User.id == user_id).limit(1))).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
user.private_portrait_image_limit = max(0, int(limit))
|
||||
await db.flush()
|
||||
return user
|
||||
def _provider_url(asset: PrivatePortraitAsset) -> str | None:
|
||||
return f"{PRIVATE_PORTRAIT_ASSET_URI_PREFIX}{asset.remote_asset_id}" if asset.remote_asset_id else None
|
||||
|
||||
|
||||
async def count_user_counting_image_assets(db: AsyncSession, *, user_id: str) -> int:
|
||||
statuses = [PrivatePortraitAssetStatus.CREATING.value, PrivatePortraitAssetStatus.PROCESSING.value, PrivatePortraitAssetStatus.ACTIVE.value]
|
||||
total = (await db.execute(select(func.count(PrivatePortraitAsset.id)).where(PrivatePortraitAsset.user_id == user_id, PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.IMAGE.value, PrivatePortraitAsset.deleted_at.is_(None), PrivatePortraitAsset.status.in_(statuses)))).scalar_one()
|
||||
return int(total or 0)
|
||||
def _poll_interval_seconds(asset_type: str) -> int:
|
||||
if asset_type == PrivatePortraitAssetType.VIDEO.value:
|
||||
return PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS
|
||||
return PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS
|
||||
|
||||
|
||||
async def _lock_user_for_upload(db: AsyncSession, *, user_id: str) -> User:
|
||||
# 锁 users 行,避免并发绕过用户总量限制。SQLite 会忽略 FOR UPDATE,不影响本地开发。
|
||||
user = (await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1))).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return user
|
||||
def _poll_max_count(asset_type: str) -> int:
|
||||
if asset_type == PrivatePortraitAssetType.VIDEO.value:
|
||||
return PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT
|
||||
return PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT
|
||||
|
||||
|
||||
def _assert_enabled_asset_type(asset_type: str) -> None:
|
||||
if asset_type not in {item.value for item in PrivatePortraitAssetType}:
|
||||
raise HTTPException(status_code=400, detail="asset_type 仅支持 Image/Video,Audio 暂未开放")
|
||||
if asset_type not in PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES:
|
||||
raise HTTPException(status_code=400, detail="Audio 暂未开放,当前仅支持 Image/Video")
|
||||
|
||||
|
||||
def validate_session_to_out(session: PrivatePortraitValidateSession, *, include_user: bool = False) -> PrivatePortraitValidateSessionOut:
|
||||
@@ -137,12 +140,14 @@ def validate_session_to_out(session: PrivatePortraitValidateSession, *, include_
|
||||
|
||||
|
||||
def asset_to_out(asset: PrivatePortraitAsset, *, project_name: str | None = None, include_user: bool = False) -> PrivatePortraitAssetOut:
|
||||
display_url = _asset_display_url(asset)
|
||||
return PrivatePortraitAssetOut(
|
||||
id=asset.id,
|
||||
user_id=asset.user_id if include_user else None,
|
||||
project_id=asset.project_id,
|
||||
project_name=project_name,
|
||||
group_id=asset.group_id,
|
||||
library_type=asset.library_type,
|
||||
remote_group_id=asset.remote_group_id,
|
||||
remote_asset_id=asset.remote_asset_id,
|
||||
remote_project_name=asset.remote_project_name,
|
||||
@@ -150,8 +155,14 @@ def asset_to_out(asset: PrivatePortraitAsset, *, project_name: str | None = None
|
||||
name=asset.name,
|
||||
source_url=asset.source_url,
|
||||
preview_url=asset.preview_url,
|
||||
display_url=display_url,
|
||||
provider_url=_provider_url(asset),
|
||||
remote_url=asset.remote_url,
|
||||
remote_url_expired_at=asset.remote_url_expired_at,
|
||||
video_duration=asset.video_duration,
|
||||
video_cover_url=asset.video_cover_url,
|
||||
file_size=asset.file_size,
|
||||
mime_type=asset.mime_type,
|
||||
status=asset.status,
|
||||
moderation=_loads(asset.moderation_json),
|
||||
last_poll_at=asset.last_poll_at,
|
||||
@@ -166,15 +177,18 @@ def asset_to_out(asset: PrivatePortraitAsset, *, project_name: str | None = None
|
||||
)
|
||||
|
||||
|
||||
async def _get_existing_active_group(db: AsyncSession, *, project_id: str) -> PrivatePortraitAssetGroup | None:
|
||||
return (
|
||||
await db.execute(
|
||||
select(PrivatePortraitAssetGroup)
|
||||
.where(
|
||||
async def _get_existing_active_group(db: AsyncSession, *, project_id: str, library_type: str | None = None) -> PrivatePortraitAssetGroup | None:
|
||||
filters = [
|
||||
PrivatePortraitAssetGroup.project_id == project_id,
|
||||
PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value,
|
||||
PrivatePortraitAssetGroup.deleted_at.is_(None),
|
||||
)
|
||||
]
|
||||
if library_type:
|
||||
filters.append(PrivatePortraitAssetGroup.library_type == library_type)
|
||||
return (
|
||||
await db.execute(
|
||||
select(PrivatePortraitAssetGroup)
|
||||
.where(*filters)
|
||||
.order_by(PrivatePortraitAssetGroup.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
@@ -182,7 +196,9 @@ async def _get_existing_active_group(db: AsyncSession, *, project_id: str) -> Pr
|
||||
|
||||
|
||||
async def _ensure_project_can_validate(db: AsyncSession, *, project: PrivatePortraitProject) -> PrivatePortraitValidateSession | None:
|
||||
active_group = await _get_existing_active_group(db, project_id=project.id)
|
||||
if project.library_type != PrivatePortraitLibraryType.REAL_PERSON.value:
|
||||
raise HTTPException(status_code=400, detail="虚拟人像项目不支持真人认证")
|
||||
active_group = await _get_existing_active_group(db, project_id=project.id, library_type=project.library_type)
|
||||
if active_group or project.status == PrivatePortraitProjectStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=409, detail="该真人素材项目已完成认证,不能重复认证")
|
||||
|
||||
@@ -206,12 +222,7 @@ async def _ensure_project_can_validate(db: AsyncSession, *, project: PrivatePort
|
||||
select(PrivatePortraitValidateSession)
|
||||
.where(
|
||||
PrivatePortraitValidateSession.project_id == project.id,
|
||||
PrivatePortraitValidateSession.status.in_(
|
||||
[
|
||||
PrivatePortraitValidateSessionStatus.CREATED.value,
|
||||
PrivatePortraitValidateSessionStatus.CALLBACK_SUCCESS.value,
|
||||
]
|
||||
),
|
||||
PrivatePortraitValidateSession.status.in_([PrivatePortraitValidateSessionStatus.CREATED.value, PrivatePortraitValidateSessionStatus.CALLBACK_SUCCESS.value]),
|
||||
PrivatePortraitValidateSession.expired_at.is_not(None),
|
||||
PrivatePortraitValidateSession.expired_at > now,
|
||||
)
|
||||
@@ -223,7 +234,7 @@ async def _ensure_project_can_validate(db: AsyncSession, *, project: PrivatePort
|
||||
|
||||
|
||||
async def create_validate_session(db: AsyncSession, *, user_id: str, project_id: str, callback_redirect_url: str | None = None) -> PrivatePortraitValidateSession:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id)
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
reusable_session = await _ensure_project_can_validate(db, project=project)
|
||||
if reusable_session:
|
||||
return reusable_session
|
||||
@@ -246,10 +257,8 @@ async def create_validate_session(db: AsyncSession, *, user_id: str, project_id:
|
||||
session.h5_link = resp.get("H5Link") or resp.get("h5Link")
|
||||
session.raw_response_json = _json(resp)
|
||||
await db.flush()
|
||||
# created_at / updated_at 来自数据库默认值或 onupdate,flush 后可能处于 expired 状态。
|
||||
# 在 async SQLAlchemy 下,响应转换时同步读取 expired 字段会触发 MissingGreenlet。
|
||||
await db.refresh(session)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_SESSION_CREATE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, session_id=session.id, detail={"remote_project_name": project.remote_project_name})
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_SESSION_CREATE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, session_id=session.id, detail={"remote_project_name": project.remote_project_name, "library_type": project.library_type})
|
||||
return session
|
||||
except Exception as exc:
|
||||
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
|
||||
@@ -259,6 +268,7 @@ async def create_validate_session(db: AsyncSession, *, user_id: str, project_id:
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_SESSION_CREATE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, session_id=session.id, exc=exc)
|
||||
raise
|
||||
|
||||
|
||||
async def get_validate_session(db: AsyncSession, *, user_id: str | None, session_id: str) -> PrivatePortraitValidateSession:
|
||||
filters = [PrivatePortraitValidateSession.id == session_id]
|
||||
if user_id is not None:
|
||||
@@ -304,7 +314,7 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
|
||||
raise HTTPException(status_code=400, detail=session.error_message)
|
||||
|
||||
try:
|
||||
existing_group = await _get_existing_active_group(db, project_id=session.project_id)
|
||||
existing_group = await _get_existing_active_group(db, project_id=session.project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
if existing_group:
|
||||
session.remote_group_id = existing_group.remote_group_id
|
||||
session.status = PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value
|
||||
@@ -330,10 +340,11 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
|
||||
id=generate_id(),
|
||||
user_id=session.user_id,
|
||||
project_id=session.project_id,
|
||||
library_type=PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
remote_group_id=group_id,
|
||||
remote_group_name=remote_group_name,
|
||||
remote_project_name=session.remote_project_name,
|
||||
group_type=PRIVATE_PORTRAIT_GROUP_TYPE,
|
||||
group_type=PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE,
|
||||
status=PrivatePortraitAssetGroupStatus.ACTIVE.value,
|
||||
raw_response_json=_json(resp),
|
||||
)
|
||||
@@ -347,7 +358,7 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
|
||||
await refresh_project_counters(db, [session.project_id])
|
||||
await db.flush()
|
||||
await db.refresh(session)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, group_id=group.id, detail={"remote_group_id": group_id, "remote_project_name": session.remote_project_name})
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, group_id=group.id, detail={"remote_group_id": group_id, "remote_project_name": session.remote_project_name, "library_type": PrivatePortraitLibraryType.REAL_PERSON.value})
|
||||
return session
|
||||
except Exception as exc:
|
||||
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
|
||||
@@ -358,51 +369,67 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_FAILED.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, exc=exc)
|
||||
raise
|
||||
|
||||
async def get_project_active_group(db: AsyncSession, *, user_id: str, project_id: str) -> PrivatePortraitAssetGroup:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id)
|
||||
|
||||
async def get_project_active_group(db: AsyncSession, *, user_id: str, project_id: str, library_type: str | None = None) -> PrivatePortraitAssetGroup:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
|
||||
if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail="请先完成真人授权认证,再上传素材")
|
||||
result = await db.execute(select(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.user_id == user_id, PrivatePortraitAssetGroup.project_id == project_id, PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value, PrivatePortraitAssetGroup.deleted_at.is_(None)).order_by(PrivatePortraitAssetGroup.created_at.desc()).limit(1))
|
||||
detail = "请先完成真人授权认证,再上传素材" if project.library_type == PrivatePortraitLibraryType.REAL_PERSON.value else "虚拟人像素材组尚未创建成功,不能上传素材"
|
||||
raise HTTPException(status_code=400, detail=detail)
|
||||
result = await db.execute(
|
||||
select(PrivatePortraitAssetGroup)
|
||||
.where(
|
||||
PrivatePortraitAssetGroup.user_id == user_id,
|
||||
PrivatePortraitAssetGroup.project_id == project_id,
|
||||
PrivatePortraitAssetGroup.library_type == project.library_type,
|
||||
PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value,
|
||||
PrivatePortraitAssetGroup.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(PrivatePortraitAssetGroup.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
group = result.scalar_one_or_none()
|
||||
if not group:
|
||||
raise HTTPException(status_code=400, detail="请先完成真人授权认证,再上传素材")
|
||||
raise HTTPException(status_code=400, detail="项目没有可用的远程素材组")
|
||||
return group
|
||||
|
||||
|
||||
async def create_asset(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitAssetCreate) -> PrivatePortraitAsset:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id)
|
||||
async def create_asset(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
payload: PrivatePortraitAssetCreate,
|
||||
library_type: str | None = None,
|
||||
) -> PrivatePortraitAsset:
|
||||
_assert_enabled_asset_type(payload.asset_type)
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
|
||||
if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail="项目正在真人认证或认证未通过,不能上传素材")
|
||||
if payload.asset_type != PrivatePortraitAssetType.IMAGE.value:
|
||||
raise HTTPException(status_code=400, detail="第一版真人素材库仅开放 Image 图片素材")
|
||||
user = await _lock_user_for_upload(db, user_id=user_id)
|
||||
limit = int(getattr(user, "private_portrait_image_limit", PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT) or 0)
|
||||
if limit <= 0:
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_REJECT_DISABLED.value, event_status=PrivatePortraitEventStatus.FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, message="用户真人素材模块未启用")
|
||||
raise HTTPException(status_code=403, detail="真人素材库未启用")
|
||||
current_count = await count_user_counting_image_assets(db, user_id=user_id)
|
||||
if current_count >= limit:
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_REJECT_MAX_LIMIT.value, event_status=PrivatePortraitEventStatus.FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, detail={"current_count": current_count, "limit": limit})
|
||||
raise HTTPException(status_code=400, detail=f"你的真人素材库最多可上传 {limit} 张图片,请删除已有素材后再上传")
|
||||
raise HTTPException(status_code=400, detail="项目未激活,不能上传素材")
|
||||
|
||||
group = await get_project_active_group(db, user_id=user_id, project_id=project.id)
|
||||
limit, current_count = await ensure_private_portrait_asset_quota_available(db, user_id=user_id, project_id=project_id, library_type=project.library_type, asset_type=payload.asset_type)
|
||||
group = await get_project_active_group(db, user_id=user_id, project_id=project.id, library_type=project.library_type)
|
||||
public_url = _public_url(payload.url)
|
||||
asset = PrivatePortraitAsset(
|
||||
id=generate_id(),
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
group_id=group.id,
|
||||
library_type=project.library_type,
|
||||
remote_group_id=group.remote_group_id,
|
||||
remote_project_name=project.remote_project_name,
|
||||
asset_type=payload.asset_type,
|
||||
name=payload.name,
|
||||
source_url=public_url,
|
||||
preview_url=payload.url,
|
||||
video_duration=payload.video_duration,
|
||||
video_cover_url=payload.video_cover_url,
|
||||
file_size=payload.file_size,
|
||||
mime_type=payload.mime_type,
|
||||
status=PrivatePortraitAssetStatus.CREATING.value,
|
||||
)
|
||||
db.add(asset)
|
||||
await db.flush()
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"limit": limit, "current_count": current_count, "remote_project_name": project.remote_project_name})
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"asset_limit": limit, "used_asset_count": current_count, "library_type": project.library_type, "asset_type": payload.asset_type, "remote_project_name": project.remote_project_name})
|
||||
try:
|
||||
remote_resp = await ArkPrivateAssetClient().create_asset(project_name=project.remote_project_name, group_id=group.remote_group_id, url=public_url, asset_type=payload.asset_type, name=payload.name)
|
||||
remote_asset_id = remote_resp.get("Id") or remote_resp.get("AssetId") or remote_resp.get("assetId")
|
||||
@@ -411,14 +438,12 @@ async def create_asset(db: AsyncSession, *, user_id: str, project_id: str, paylo
|
||||
now = datetime.now(timezone.utc)
|
||||
asset.remote_asset_id = remote_asset_id
|
||||
asset.status = PrivatePortraitAssetStatus.PROCESSING.value
|
||||
asset.next_poll_at = now + timedelta(seconds=PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS)
|
||||
asset.next_poll_at = now + timedelta(seconds=_poll_interval_seconds(asset.asset_type))
|
||||
asset.raw_response_json = _json(remote_resp)
|
||||
await refresh_project_counters(db, [project.id])
|
||||
await db.flush()
|
||||
# created_at / updated_at 来自数据库默认值或 onupdate,flush 后可能处于 expired 状态。
|
||||
# 在 async SQLAlchemy 下,响应转换时同步读取 expired 字段会触发 MissingGreenlet。
|
||||
await db.refresh(asset)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"remote_asset_id": remote_asset_id, "remote_project_name": project.remote_project_name})
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"remote_asset_id": remote_asset_id, "remote_project_name": project.remote_project_name, "library_type": project.library_type, "asset_type": asset.asset_type})
|
||||
return asset
|
||||
except Exception as exc:
|
||||
asset.status = PrivatePortraitAssetStatus.FAILED.value
|
||||
@@ -434,11 +459,11 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id:
|
||||
filters.append(PrivatePortraitAsset.user_id == user_id)
|
||||
asset = (await db.execute(select(PrivatePortraitAsset).where(*filters).limit(1))).scalar_one_or_none()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="真人素材不存在")
|
||||
raise HTTPException(status_code=404, detail="私域人像素材不存在")
|
||||
if asset.deleted_at is not None:
|
||||
raise HTTPException(status_code=400, detail="真人素材已删除")
|
||||
raise HTTPException(status_code=400, detail="私域人像素材已删除")
|
||||
if not asset.remote_asset_id:
|
||||
raise HTTPException(status_code=400, detail="真人素材尚未创建远程 Asset")
|
||||
raise HTTPException(status_code=400, detail="私域人像素材尚未创建远程 Asset")
|
||||
|
||||
source = PrivatePortraitEventSource.CELERY.value if user_id is None else PrivatePortraitEventSource.API.value
|
||||
log_operation_event(
|
||||
@@ -449,12 +474,7 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id:
|
||||
user_id=asset.user_id,
|
||||
project_id=asset.project_id,
|
||||
asset_id=asset.id,
|
||||
detail={
|
||||
"status": asset.status,
|
||||
"poll_count": int(asset.poll_count or 0),
|
||||
"remote_asset_id": asset.remote_asset_id,
|
||||
"remote_project_name": asset.remote_project_name,
|
||||
},
|
||||
detail={"status": asset.status, "poll_count": int(asset.poll_count or 0), "remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type},
|
||||
)
|
||||
try:
|
||||
remote_resp = await ArkPrivateAssetClient(for_celery=(user_id is None)).get_asset(project_name=asset.remote_project_name, asset_id=asset.remote_asset_id)
|
||||
@@ -467,24 +487,15 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id:
|
||||
asset.status = status
|
||||
asset.remote_url = remote_resp.get("URL") or remote_resp.get("url") or asset.remote_url
|
||||
asset.moderation_json = _json(remote_resp.get("Moderation") or remote_resp.get("moderation"))
|
||||
max_count = _poll_max_count(asset.asset_type)
|
||||
|
||||
if asset.status == PrivatePortraitAssetStatus.PROCESSING.value and asset.poll_count >= PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT:
|
||||
if asset.status == PrivatePortraitAssetStatus.PROCESSING.value and asset.poll_count >= max_count:
|
||||
asset.status = PrivatePortraitAssetStatus.FAILED.value
|
||||
asset.error_message = "素材入库轮询超时"
|
||||
asset.next_poll_at = None
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_POLL_TIMEOUT.value,
|
||||
event_status=PrivatePortraitEventStatus.FAILED.value,
|
||||
source=source,
|
||||
user_id=asset.user_id,
|
||||
project_id=asset.project_id,
|
||||
asset_id=asset.id,
|
||||
detail={"poll_count": asset.poll_count, "max_count": PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT, "remote_asset_id": asset.remote_asset_id},
|
||||
error=asset.error_message,
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_POLL_TIMEOUT.value, event_status=PrivatePortraitEventStatus.FAILED.value, source=source, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"poll_count": asset.poll_count, "max_count": max_count, "remote_asset_id": asset.remote_asset_id, "library_type": asset.library_type, "asset_type": asset.asset_type}, error=asset.error_message)
|
||||
elif asset.status == PrivatePortraitAssetStatus.PROCESSING.value:
|
||||
asset.next_poll_at = now + timedelta(seconds=PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS)
|
||||
asset.next_poll_at = now + timedelta(seconds=_poll_interval_seconds(asset.asset_type))
|
||||
else:
|
||||
asset.next_poll_at = None
|
||||
|
||||
@@ -493,23 +504,25 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id:
|
||||
await refresh_project_counters(db, [asset.project_id])
|
||||
await db.flush()
|
||||
await db.refresh(asset)
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_SYNC_SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=source,
|
||||
user_id=asset.user_id,
|
||||
project_id=asset.project_id,
|
||||
asset_id=asset.id,
|
||||
detail={"status": asset.status, "remote_asset_id": asset.remote_asset_id, "next_poll_at": asset.next_poll_at, "poll_count": asset.poll_count},
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_SYNC_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=source, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"status": asset.status, "remote_asset_id": asset.remote_asset_id, "next_poll_at": asset.next_poll_at, "poll_count": asset.poll_count, "library_type": asset.library_type, "asset_type": asset.asset_type})
|
||||
return asset
|
||||
except Exception as exc:
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_SYNC_FAILED.value, source=source, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, exc=exc)
|
||||
raise
|
||||
|
||||
|
||||
async def list_assets(db: AsyncSession, *, user_id: str | None, project_id: str | None = None, status: str | None = None, keyword: str | None = None, page: int = 1, page_size: int = 20) -> tuple[list[PrivatePortraitAsset], int, dict[str, str]]:
|
||||
async def list_assets(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str | None,
|
||||
project_id: str | None = None,
|
||||
status: str | None = None,
|
||||
keyword: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
library_type: str | None = None,
|
||||
asset_type: str | None = None,
|
||||
) -> tuple[list[PrivatePortraitAsset], int, dict[str, str]]:
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
filters = [PrivatePortraitAsset.deleted_at.is_(None)]
|
||||
@@ -517,6 +530,10 @@ async def list_assets(db: AsyncSession, *, user_id: str | None, project_id: str
|
||||
filters.append(PrivatePortraitAsset.user_id == user_id)
|
||||
if project_id:
|
||||
filters.append(PrivatePortraitAsset.project_id == project_id)
|
||||
if library_type:
|
||||
filters.append(PrivatePortraitAsset.library_type == library_type)
|
||||
if asset_type:
|
||||
filters.append(PrivatePortraitAsset.asset_type == asset_type)
|
||||
if status:
|
||||
filters.append(PrivatePortraitAsset.status == status)
|
||||
if keyword:
|
||||
@@ -532,15 +549,35 @@ async def list_assets(db: AsyncSession, *, user_id: str | None, project_id: str
|
||||
return assets, int(total or 0), project_name_map
|
||||
|
||||
|
||||
async def list_selectable_assets(db: AsyncSession, *, user_id: str, project_id: str | None = None, keyword: str | None = None, page: int = 1, page_size: int = 20) -> tuple[list[PrivatePortraitSelectableAssetOut], int]:
|
||||
assets, total, project_name_map = await list_assets(db, user_id=user_id, project_id=project_id, status=PrivatePortraitAssetStatus.ACTIVE.value, keyword=keyword, page=page, page_size=page_size)
|
||||
return [PrivatePortraitSelectableAssetOut(id=asset.id, project_id=asset.project_id, project_name=project_name_map.get(asset.project_id, ""), name=asset.name, asset_type=asset.asset_type, preview_url=asset.preview_url or asset.remote_url, status=asset.status, created_at=asset.created_at) for asset in assets], total
|
||||
async def list_selectable_assets(db: AsyncSession, *, user_id: str, project_id: str | None = None, keyword: str | None = None, page: int = 1, page_size: int = 20, library_type: str | None = None, asset_type: str | None = None) -> tuple[list[PrivatePortraitSelectableAssetOut], int]:
|
||||
assets, total, project_name_map = await list_assets(db, user_id=user_id, project_id=project_id, status=PrivatePortraitAssetStatus.ACTIVE.value, keyword=keyword, page=page, page_size=page_size, library_type=library_type, asset_type=asset_type)
|
||||
return [
|
||||
PrivatePortraitSelectableAssetOut(
|
||||
id=asset.id,
|
||||
project_id=asset.project_id,
|
||||
project_name=project_name_map.get(asset.project_id, ""),
|
||||
library_type=asset.library_type,
|
||||
name=asset.name,
|
||||
asset_type=asset.asset_type,
|
||||
preview_url=asset.preview_url or asset.remote_url,
|
||||
display_url=_asset_display_url(asset),
|
||||
provider_url=_provider_url(asset),
|
||||
video_duration=asset.video_duration,
|
||||
video_cover_url=asset.video_cover_url,
|
||||
status=asset.status,
|
||||
created_at=asset.created_at,
|
||||
)
|
||||
for asset in assets
|
||||
], total
|
||||
|
||||
|
||||
async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str) -> PrivatePortraitAsset:
|
||||
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == user_id, PrivatePortraitAsset.deleted_at.is_(None)).limit(1))).scalar_one_or_none()
|
||||
async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str, library_type: str | None = None) -> PrivatePortraitAsset:
|
||||
filters = [PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == user_id, PrivatePortraitAsset.deleted_at.is_(None)]
|
||||
if library_type:
|
||||
filters.append(PrivatePortraitAsset.library_type == library_type)
|
||||
asset = (await db.execute(select(PrivatePortraitAsset).where(*filters).limit(1))).scalar_one_or_none()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="真人素材不存在")
|
||||
raise HTTPException(status_code=404, detail="私域人像素材不存在")
|
||||
now = datetime.now(timezone.utc)
|
||||
asset.deleted_at = now
|
||||
asset.status = PrivatePortraitAssetStatus.LOCAL_DELETED.value
|
||||
@@ -548,64 +585,30 @@ async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str) ->
|
||||
await refresh_project_counters(db, [asset.project_id])
|
||||
await db.flush()
|
||||
await db.refresh(asset)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_LOCAL.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name})
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_LOCAL.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type})
|
||||
return asset
|
||||
|
||||
|
||||
async def delete_asset_remote(db: AsyncSession, *, asset_id: str) -> None:
|
||||
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id).limit(1))).scalar_one_or_none()
|
||||
if not asset:
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value,
|
||||
event_status=PrivatePortraitEventStatus.SKIPPED.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
asset_id=asset_id,
|
||||
message="远程删除跳过:本地素材不存在",
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.SKIPPED.value, source=PrivatePortraitEventSource.CELERY.value, asset_id=asset_id, message="远程删除跳过:本地素材不存在")
|
||||
return
|
||||
if not asset.remote_asset_id:
|
||||
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
|
||||
asset.remote_delete_error = None
|
||||
await db.flush()
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.SKIPPED.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
user_id=asset.user_id,
|
||||
project_id=asset.project_id,
|
||||
asset_id=asset.id,
|
||||
message="远程删除跳过:素材没有 remote_asset_id",
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SKIPPED.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, message="远程删除跳过:素材没有 remote_asset_id")
|
||||
return
|
||||
now = datetime.now(timezone.utc)
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value,
|
||||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
user_id=asset.user_id,
|
||||
project_id=asset.project_id,
|
||||
asset_id=asset.id,
|
||||
detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name},
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type})
|
||||
try:
|
||||
await ArkPrivateAssetClient(for_celery=True).delete_asset(project_name=asset.remote_project_name, asset_id=asset.remote_asset_id)
|
||||
asset.status = PrivatePortraitAssetStatus.REMOTE_DELETED.value
|
||||
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
|
||||
asset.remote_deleted_at = now
|
||||
asset.remote_delete_error = None
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
user_id=asset.user_id,
|
||||
project_id=asset.project_id,
|
||||
asset_id=asset.id,
|
||||
detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name},
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type})
|
||||
except Exception as exc:
|
||||
asset.status = PrivatePortraitAssetStatus.DELETE_FAILED.value
|
||||
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value
|
||||
@@ -619,45 +622,18 @@ async def _delete_asset_group_remote(db: AsyncSession, *, group: PrivatePortrait
|
||||
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
|
||||
group.remote_delete_error = None
|
||||
await db.flush()
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.SKIPPED.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
user_id=group.user_id,
|
||||
project_id=group.project_id,
|
||||
group_id=group.id,
|
||||
message="远程删除跳过:素材组没有 remote_group_id",
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SKIPPED.value, source=PrivatePortraitEventSource.CELERY.value, user_id=group.user_id, project_id=group.project_id, group_id=group.id, message="远程删除跳过:素材组没有 remote_group_id")
|
||||
return
|
||||
client = client or ArkPrivateAssetClient(for_celery=True)
|
||||
now = datetime.now(timezone.utc)
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value,
|
||||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
user_id=group.user_id,
|
||||
project_id=group.project_id,
|
||||
group_id=group.id,
|
||||
detail={"remote_group_id": group.remote_group_id, "remote_project_name": group.remote_project_name},
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, user_id=group.user_id, project_id=group.project_id, group_id=group.id, detail={"remote_group_id": group.remote_group_id, "remote_project_name": group.remote_project_name, "library_type": group.library_type})
|
||||
try:
|
||||
await client.delete_asset_group(project_name=group.remote_project_name, group_id=group.remote_group_id)
|
||||
group.status = PrivatePortraitAssetGroupStatus.REMOTE_DELETED.value
|
||||
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
|
||||
group.remote_deleted_at = now
|
||||
group.remote_delete_error = None
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
user_id=group.user_id,
|
||||
project_id=group.project_id,
|
||||
group_id=group.id,
|
||||
detail={"remote_group_id": group.remote_group_id, "remote_project_name": group.remote_project_name},
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, user_id=group.user_id, project_id=group.project_id, group_id=group.id, detail={"remote_group_id": group.remote_group_id, "remote_project_name": group.remote_project_name, "library_type": group.library_type})
|
||||
except Exception as exc:
|
||||
group.status = PrivatePortraitAssetGroupStatus.DELETE_FAILED.value
|
||||
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value
|
||||
@@ -667,14 +643,7 @@ async def _delete_asset_group_remote(db: AsyncSession, *, group: PrivatePortrait
|
||||
|
||||
|
||||
async def delete_project_remote(db: AsyncSession, *, project_id: str) -> None:
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value,
|
||||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
project_id=project_id,
|
||||
message="开始远程删除真人素材项目资源",
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, project_id=project_id, message="开始远程删除私域人像素材项目资源")
|
||||
rows = await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.project_id == project_id))
|
||||
for asset in rows.scalars().all():
|
||||
await delete_asset_remote(db, asset_id=asset.id)
|
||||
@@ -682,14 +651,7 @@ async def delete_project_remote(db: AsyncSession, *, project_id: str) -> None:
|
||||
client = ArkPrivateAssetClient(for_celery=True)
|
||||
for group in groups.scalars().all():
|
||||
await _delete_asset_group_remote(db, group=group, client=client)
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
project_id=project_id,
|
||||
message="远程删除真人素材项目资源完成",
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, project_id=project_id, message="远程删除私域人像素材项目资源完成")
|
||||
await db.flush()
|
||||
|
||||
|
||||
@@ -707,13 +669,7 @@ async def poll_due_assets_once(db: AsyncSession, *, limit: int) -> int:
|
||||
.limit(limit)
|
||||
)
|
||||
ids = [row[0] for row in rows.all()]
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_START.value,
|
||||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
detail={"limit": limit, "matched_count": len(ids)},
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, detail={"limit": limit, "matched_count": len(ids)})
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
for asset_id in ids:
|
||||
@@ -722,38 +678,15 @@ async def poll_due_assets_once(db: AsyncSession, *, limit: int) -> int:
|
||||
success_count += 1
|
||||
except Exception as exc:
|
||||
failed_count += 1
|
||||
log_operation_error(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_POLL_FAILED.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
asset_id=asset_id,
|
||||
exc=exc,
|
||||
)
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_DONE.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value if failed_count == 0 else PrivatePortraitEventStatus.WARNING.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
detail={"matched_count": len(ids), "success_count": success_count, "failed_count": failed_count},
|
||||
)
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_POLL_FAILED.value, source=PrivatePortraitEventSource.CELERY.value, asset_id=asset_id, exc=exc)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_DONE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value if failed_count == 0 else PrivatePortraitEventStatus.WARNING.value, source=PrivatePortraitEventSource.CELERY.value, detail={"matched_count": len(ids), "success_count": success_count, "failed_count": failed_count})
|
||||
return len(ids)
|
||||
|
||||
|
||||
async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[str, int]:
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_START.value,
|
||||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
detail={"limit": limit},
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, detail={"limit": limit})
|
||||
statuses = [PrivatePortraitRemoteDeleteStatus.PENDING.value, PrivatePortraitRemoteDeleteStatus.FAILED.value]
|
||||
asset_rows = await db.execute(
|
||||
select(PrivatePortraitAsset.id)
|
||||
.where(PrivatePortraitAsset.remote_delete_status.in_(statuses))
|
||||
.order_by(PrivatePortraitAsset.updated_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
asset_rows = await db.execute(select(PrivatePortraitAsset.id).where(PrivatePortraitAsset.remote_delete_status.in_(statuses)).order_by(PrivatePortraitAsset.updated_at.asc()).limit(limit))
|
||||
asset_ids = [row[0] for row in asset_rows.all()]
|
||||
for asset_id in asset_ids:
|
||||
await delete_asset_remote(db, asset_id=asset_id)
|
||||
@@ -761,12 +694,7 @@ async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[s
|
||||
remaining = max(0, limit - len(asset_ids))
|
||||
group_count = 0
|
||||
if remaining > 0:
|
||||
group_rows = await db.execute(
|
||||
select(PrivatePortraitAssetGroup)
|
||||
.where(PrivatePortraitAssetGroup.remote_delete_status.in_(statuses))
|
||||
.order_by(PrivatePortraitAssetGroup.updated_at.asc())
|
||||
.limit(remaining)
|
||||
)
|
||||
group_rows = await db.execute(select(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.remote_delete_status.in_(statuses)).order_by(PrivatePortraitAssetGroup.updated_at.asc()).limit(remaining))
|
||||
client = ArkPrivateAssetClient(for_celery=True)
|
||||
groups = list(group_rows.scalars().all())
|
||||
group_count = len(groups)
|
||||
@@ -774,12 +702,5 @@ async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[s
|
||||
await _delete_asset_group_remote(db, group=group, client=client)
|
||||
|
||||
result = {"asset_count": len(asset_ids), "group_count": group_count, "total_count": len(asset_ids) + group_count}
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_DONE.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
detail=result,
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_DONE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, detail=result)
|
||||
return result
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@ from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
||||
PrivatePortraitAssetGroupStatus,
|
||||
PrivatePortraitAssetStatus,
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitProjectStatus,
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
)
|
||||
@@ -27,16 +29,24 @@ DOMAIN = "private_portrait"
|
||||
|
||||
def _safe_slug(value: str, *, max_length: int = 80) -> str:
|
||||
value = (value or "").strip().lower()
|
||||
# 先保留常见英文数字连字符;中文等字符统一转 _,仅用于本地项目 slug。
|
||||
value = re.sub(r"[^a-z0-9_-]+", "_", value)
|
||||
value = re.sub(r"_+", "_", value).strip("_-")
|
||||
return (value[:max_length] or "project")
|
||||
|
||||
|
||||
def _status_for_created_project(library_type: str) -> str:
|
||||
if library_type == PrivatePortraitLibraryType.REAL_PERSON.value:
|
||||
return PrivatePortraitProjectStatus.VALIDATING.value
|
||||
if library_type == PrivatePortraitLibraryType.AIGC_VIRTUAL.value:
|
||||
return PrivatePortraitProjectStatus.CREATING_REMOTE_GROUP.value
|
||||
raise HTTPException(status_code=400, detail="library_type 不支持")
|
||||
|
||||
|
||||
def project_to_out(project: PrivatePortraitProject, *, include_user: bool = False) -> PrivatePortraitProjectOut:
|
||||
return PrivatePortraitProjectOut(
|
||||
id=project.id,
|
||||
user_id=project.user_id if include_user else None,
|
||||
library_type=project.library_type,
|
||||
name=project.name,
|
||||
name_slug=project.name_slug,
|
||||
remote_project_name=project.remote_project_name,
|
||||
@@ -44,37 +54,57 @@ def project_to_out(project: PrivatePortraitProject, *, include_user: bool = Fals
|
||||
status=project.status,
|
||||
asset_group_count=project.asset_group_count or 0,
|
||||
asset_count=project.asset_count or 0,
|
||||
image_asset_count=getattr(project, "image_asset_count", 0) or 0,
|
||||
video_asset_count=getattr(project, "video_asset_count", 0) or 0,
|
||||
active_asset_count=project.active_asset_count or 0,
|
||||
active_image_asset_count=getattr(project, "active_image_asset_count", 0) or 0,
|
||||
active_video_asset_count=getattr(project, "active_video_asset_count", 0) or 0,
|
||||
last_used_at=project.last_used_at,
|
||||
created_at=project.created_at,
|
||||
updated_at=project.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def get_user_project(db: AsyncSession, *, user_id: str, project_id: str) -> PrivatePortraitProject:
|
||||
result = await db.execute(
|
||||
select(PrivatePortraitProject).where(
|
||||
async def get_user_project(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
library_type: str | None = None,
|
||||
) -> PrivatePortraitProject:
|
||||
filters = [
|
||||
PrivatePortraitProject.id == project_id,
|
||||
PrivatePortraitProject.user_id == user_id,
|
||||
PrivatePortraitProject.deleted_at.is_(None),
|
||||
).limit(1)
|
||||
)
|
||||
]
|
||||
if library_type:
|
||||
filters.append(PrivatePortraitProject.library_type == library_type)
|
||||
result = await db.execute(select(PrivatePortraitProject).where(*filters).limit(1))
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="真人素材项目不存在")
|
||||
raise HTTPException(status_code=404, detail="私域人像素材项目不存在")
|
||||
return project
|
||||
|
||||
|
||||
async def create_project(db: AsyncSession, *, user_id: str, payload: PrivatePortraitProjectCreate) -> PrivatePortraitProject:
|
||||
async def create_project(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
payload: PrivatePortraitProjectCreate,
|
||||
library_type: str = PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
status: str | None = None,
|
||||
remote_project_name: str = PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
||||
) -> PrivatePortraitProject:
|
||||
slug = _safe_slug(payload.name)
|
||||
project = PrivatePortraitProject(
|
||||
id=generate_id(),
|
||||
user_id=user_id,
|
||||
library_type=library_type,
|
||||
name=payload.name.strip(),
|
||||
name_slug=slug,
|
||||
remote_project_name=PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
||||
remote_project_name=remote_project_name,
|
||||
description=payload.description,
|
||||
status=PrivatePortraitProjectStatus.VALIDATING.value,
|
||||
status=status or _status_for_created_project(library_type),
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
@@ -85,35 +115,38 @@ async def create_project(db: AsyncSession, *, user_id: str, payload: PrivatePort
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
message="创建待认证真人素材项目",
|
||||
detail={"name": project.name, "remote_project_name": project.remote_project_name, "status": project.status},
|
||||
message="创建私域人像素材项目",
|
||||
detail={"name": project.name, "library_type": library_type, "remote_project_name": project.remote_project_name, "status": project.status},
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
async def update_project(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitProjectUpdate) -> PrivatePortraitProject:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id)
|
||||
async def update_project(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
payload: PrivatePortraitProjectUpdate,
|
||||
library_type: str | None = None,
|
||||
) -> PrivatePortraitProject:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
|
||||
before = {
|
||||
"name": project.name,
|
||||
"name_slug": project.name_slug,
|
||||
"remote_project_name": project.remote_project_name,
|
||||
"description": project.description,
|
||||
"status": project.status,
|
||||
"library_type": project.library_type,
|
||||
}
|
||||
if payload.name is not None:
|
||||
new_name = payload.name.strip()
|
||||
if new_name and new_name != project.name:
|
||||
project.name = new_name
|
||||
project.name_slug = _safe_slug(new_name)
|
||||
project.remote_project_name = PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME
|
||||
if payload.description is not None:
|
||||
project.description = payload.description
|
||||
if payload.status is not None:
|
||||
allowed_statuses = {
|
||||
PrivatePortraitProjectStatus.VALIDATING.value,
|
||||
PrivatePortraitProjectStatus.ACTIVE.value,
|
||||
PrivatePortraitProjectStatus.VALIDATE_FAILED.value,
|
||||
}
|
||||
allowed_statuses = {item.value for item in PrivatePortraitProjectStatus if item != PrivatePortraitProjectStatus.DELETED}
|
||||
if payload.status not in allowed_statuses:
|
||||
raise HTTPException(status_code=400, detail="项目状态不支持")
|
||||
project.status = payload.status
|
||||
@@ -124,6 +157,7 @@ async def update_project(db: AsyncSession, *, user_id: str, project_id: str, pay
|
||||
"remote_project_name": project.remote_project_name,
|
||||
"description": project.description,
|
||||
"status": project.status,
|
||||
"library_type": project.library_type,
|
||||
}
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
@@ -132,7 +166,7 @@ async def update_project(db: AsyncSession, *, user_id: str, project_id: str, pay
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
message="更新真人素材项目",
|
||||
message="更新私域人像素材项目",
|
||||
detail={"before": before, "after": after},
|
||||
)
|
||||
return project
|
||||
@@ -146,18 +180,27 @@ async def list_projects(
|
||||
page_size: int = 20,
|
||||
keyword: str | None = None,
|
||||
status: str | None = None,
|
||||
library_type: str | None = None,
|
||||
) -> tuple[list[PrivatePortraitProject], int]:
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
filters = [PrivatePortraitProject.deleted_at.is_(None)]
|
||||
if user_id:
|
||||
filters.append(PrivatePortraitProject.user_id == user_id)
|
||||
if library_type:
|
||||
filters.append(PrivatePortraitProject.library_type == library_type)
|
||||
if keyword:
|
||||
filters.append(PrivatePortraitProject.name.ilike(f"%{keyword.strip()}%"))
|
||||
if status:
|
||||
filters.append(PrivatePortraitProject.status == status)
|
||||
total = (await db.execute(select(func.count(PrivatePortraitProject.id)).where(*filters))).scalar_one()
|
||||
result = await db.execute(select(PrivatePortraitProject).where(*filters).order_by(PrivatePortraitProject.created_at.desc()).offset((page - 1) * page_size).limit(page_size))
|
||||
result = await db.execute(
|
||||
select(PrivatePortraitProject)
|
||||
.where(*filters)
|
||||
.order_by(PrivatePortraitProject.created_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
return list(result.scalars().all()), int(total or 0)
|
||||
|
||||
|
||||
@@ -174,27 +217,74 @@ async def refresh_project_counters(db: AsyncSession, project_ids: list[str]) ->
|
||||
select(
|
||||
PrivatePortraitAsset.project_id,
|
||||
func.count(PrivatePortraitAsset.id),
|
||||
func.sum(case((PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.IMAGE.value, 1), else_=0)),
|
||||
func.sum(case((PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.VIDEO.value, 1), else_=0)),
|
||||
func.sum(case((PrivatePortraitAsset.status == PrivatePortraitAssetStatus.ACTIVE.value, 1), else_=0)),
|
||||
func.sum(case(((PrivatePortraitAsset.status == PrivatePortraitAssetStatus.ACTIVE.value) & (PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.IMAGE.value), 1), else_=0)),
|
||||
func.sum(case(((PrivatePortraitAsset.status == PrivatePortraitAssetStatus.ACTIVE.value) & (PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.VIDEO.value), 1), else_=0)),
|
||||
)
|
||||
.where(PrivatePortraitAsset.project_id.in_(project_ids), PrivatePortraitAsset.deleted_at.is_(None))
|
||||
.group_by(PrivatePortraitAsset.project_id)
|
||||
)
|
||||
group_count_map = {pid: int(count or 0) for pid, count in group_rows.all()}
|
||||
asset_count_map: dict[str, tuple[int, int]] = {}
|
||||
for pid, total, active_total in asset_rows.all():
|
||||
asset_count_map[pid] = (int(total or 0), int(active_total or 0))
|
||||
asset_count_map: dict[str, tuple[int, int, int, int, int, int]] = {}
|
||||
for pid, total, image_total, video_total, active_total, active_image_total, active_video_total in asset_rows.all():
|
||||
asset_count_map[pid] = (
|
||||
int(total or 0),
|
||||
int(image_total or 0),
|
||||
int(video_total or 0),
|
||||
int(active_total or 0),
|
||||
int(active_image_total or 0),
|
||||
int(active_video_total or 0),
|
||||
)
|
||||
for pid in project_ids:
|
||||
total, active_total = asset_count_map.get(pid, (0, 0))
|
||||
await db.execute(update(PrivatePortraitProject).where(PrivatePortraitProject.id == pid).values(asset_group_count=group_count_map.get(pid, 0), asset_count=total, active_asset_count=active_total))
|
||||
total, image_total, video_total, active_total, active_image_total, active_video_total = asset_count_map.get(pid, (0, 0, 0, 0, 0, 0))
|
||||
await db.execute(
|
||||
update(PrivatePortraitProject)
|
||||
.where(PrivatePortraitProject.id == pid)
|
||||
.values(
|
||||
asset_group_count=group_count_map.get(pid, 0),
|
||||
asset_count=total,
|
||||
image_asset_count=image_total,
|
||||
video_asset_count=video_total,
|
||||
active_asset_count=active_total,
|
||||
active_image_asset_count=active_image_total,
|
||||
active_video_asset_count=active_video_total,
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
|
||||
|
||||
async def soft_delete_project(db: AsyncSession, *, user_id: str, project_id: str) -> PrivatePortraitProject:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id)
|
||||
async def soft_delete_project(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
library_type: str | None = None,
|
||||
) -> PrivatePortraitProject:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
|
||||
now = datetime.now(timezone.utc)
|
||||
project.deleted_at = now
|
||||
project.status = PrivatePortraitProjectStatus.DELETED.value
|
||||
await db.execute(update(PrivatePortraitAsset).where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.deleted_at.is_(None)).values(deleted_at=now, status=PrivatePortraitAssetStatus.LOCAL_DELETED.value, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value))
|
||||
await db.execute(update(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.project_id == project_id, PrivatePortraitAssetGroup.deleted_at.is_(None)).values(deleted_at=now, status=PrivatePortraitAssetGroupStatus.LOCAL_DELETED.value, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value))
|
||||
await db.execute(
|
||||
update(PrivatePortraitAsset)
|
||||
.where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.deleted_at.is_(None))
|
||||
.values(deleted_at=now, status=PrivatePortraitAssetStatus.LOCAL_DELETED.value, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
|
||||
)
|
||||
await db.execute(
|
||||
update(PrivatePortraitAssetGroup)
|
||||
.where(PrivatePortraitAssetGroup.project_id == project_id, PrivatePortraitAssetGroup.deleted_at.is_(None))
|
||||
.values(deleted_at=now, status=PrivatePortraitAssetGroupStatus.LOCAL_DELETED.value, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
|
||||
)
|
||||
await db.flush()
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, message="本地软删真人素材项目", detail={"remote_project_name": project.remote_project_name})
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.PROJECT_DELETE.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
message="本地软删私域人像素材项目",
|
||||
detail={"library_type": project.library_type, "remote_project_name": project.remote_project_name},
|
||||
)
|
||||
return project
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT,
|
||||
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
|
||||
PrivatePortraitAssetStatus,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
PrivatePortraitLibraryType,
|
||||
)
|
||||
from app.models.private_portrait import PrivatePortraitAsset
|
||||
from app.models.user import User
|
||||
from app.schemas.private_portrait import PrivatePortraitConfigOut
|
||||
from app.services.operation_log_service import log_operation_event
|
||||
|
||||
DOMAIN = "private_portrait"
|
||||
|
||||
_COUNTING_STATUSES = {
|
||||
PrivatePortraitAssetStatus.CREATING.value,
|
||||
PrivatePortraitAssetStatus.PROCESSING.value,
|
||||
PrivatePortraitAssetStatus.ACTIVE.value,
|
||||
}
|
||||
_COUNTING_LIBRARY_TYPES = {
|
||||
PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
PrivatePortraitLibraryType.AIGC_VIRTUAL.value,
|
||||
}
|
||||
|
||||
|
||||
async def get_user_or_404(db: AsyncSession, *, user_id: str, for_update: bool = False) -> User:
|
||||
stmt = select(User).where(User.id == user_id).limit(1)
|
||||
if for_update:
|
||||
stmt = stmt.with_for_update()
|
||||
user = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return user
|
||||
|
||||
|
||||
def get_user_asset_limit_value(user: User) -> int:
|
||||
return int(getattr(user, "private_portrait_asset_limit", PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT) or 0)
|
||||
|
||||
|
||||
async def count_user_counting_assets(db: AsyncSession, *, user_id: str) -> int:
|
||||
total = (
|
||||
await db.execute(
|
||||
select(func.count(PrivatePortraitAsset.id)).where(
|
||||
PrivatePortraitAsset.user_id == user_id,
|
||||
PrivatePortraitAsset.library_type.in_(_COUNTING_LIBRARY_TYPES),
|
||||
PrivatePortraitAsset.asset_type.in_(PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES),
|
||||
PrivatePortraitAsset.deleted_at.is_(None),
|
||||
PrivatePortraitAsset.status.in_(_COUNTING_STATUSES),
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
return int(total or 0)
|
||||
|
||||
|
||||
async def get_user_private_portrait_config(db: AsyncSession, *, user_id: str) -> PrivatePortraitConfigOut:
|
||||
user = await get_user_or_404(db, user_id=user_id)
|
||||
limit = get_user_asset_limit_value(user)
|
||||
used = await count_user_counting_assets(db, user_id=user_id)
|
||||
remaining = max(0, limit - used) if limit > 0 else 0
|
||||
return PrivatePortraitConfigOut(
|
||||
enabled=limit > 0,
|
||||
asset_limit=limit,
|
||||
used_asset_count=used,
|
||||
remaining_asset_count=remaining,
|
||||
image_limit=limit,
|
||||
used_image_count=used,
|
||||
remaining_image_count=remaining,
|
||||
)
|
||||
|
||||
|
||||
async def set_user_private_portrait_limit(db: AsyncSession, *, user_id: str, limit: int) -> User:
|
||||
user = await get_user_or_404(db, user_id=user_id)
|
||||
user.private_portrait_asset_limit = max(0, int(limit))
|
||||
await db.flush()
|
||||
return user
|
||||
|
||||
|
||||
async def ensure_private_portrait_asset_quota_available(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str | None = None,
|
||||
library_type: str | None = None,
|
||||
asset_type: str | None = None,
|
||||
) -> tuple[int, int]:
|
||||
user = await get_user_or_404(db, user_id=user_id, for_update=True)
|
||||
limit = get_user_asset_limit_value(user)
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.QUOTA_CHECK_START.value,
|
||||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||||
source=PrivatePortraitEventSource.SERVICE.value,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
detail={"asset_limit": limit, "library_type": library_type, "asset_type": asset_type},
|
||||
)
|
||||
if limit <= 0:
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.QUOTA_CHECK_DENY.value,
|
||||
event_status=PrivatePortraitEventStatus.FAILED.value,
|
||||
source=PrivatePortraitEventSource.SERVICE.value,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
detail={"asset_limit": limit, "library_type": library_type, "asset_type": asset_type, "reason": "disabled"},
|
||||
message="用户私域人像素材库未启用",
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="私域人像素材库未启用")
|
||||
|
||||
used = await count_user_counting_assets(db, user_id=user_id)
|
||||
if used >= limit:
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.QUOTA_CHECK_DENY.value,
|
||||
event_status=PrivatePortraitEventStatus.FAILED.value,
|
||||
source=PrivatePortraitEventSource.SERVICE.value,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
detail={"asset_limit": limit, "used_asset_count": used, "library_type": library_type, "asset_type": asset_type, "reason": "max_limit"},
|
||||
message="用户私域人像素材总量已达上限",
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=f"你的私域人像素材库最多可上传 {limit} 个素材,请删除已有素材后再上传")
|
||||
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.QUOTA_CHECK_PASS.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.SERVICE.value,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
detail={"asset_limit": limit, "used_asset_count": used, "remaining_asset_count": max(0, limit - used), "library_type": library_type, "asset_type": asset_type},
|
||||
)
|
||||
return limit, used
|
||||
@@ -0,0 +1 @@
|
||||
from app.services.private_portrait.real_person.service import *
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.private_portrait import PrivatePortraitLibraryType
|
||||
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitProjectCreate, PrivatePortraitProjectUpdate
|
||||
from app.services.private_portrait.asset_service import create_asset, create_validate_session
|
||||
from app.services.private_portrait.project_service import create_project, update_project
|
||||
|
||||
|
||||
async def create_real_person_project(db: AsyncSession, *, user_id: str, payload: PrivatePortraitProjectCreate):
|
||||
return await create_project(db, user_id=user_id, payload=payload, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
|
||||
|
||||
async def update_real_person_project(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitProjectUpdate):
|
||||
return await update_project(db, user_id=user_id, project_id=project_id, payload=payload, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
|
||||
|
||||
async def create_real_person_validate_session(db: AsyncSession, *, user_id: str, project_id: str, callback_redirect_url: str | None = None):
|
||||
return await create_validate_session(db, user_id=user_id, project_id=project_id, callback_redirect_url=callback_redirect_url)
|
||||
|
||||
|
||||
async def create_real_person_asset(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitAssetCreate):
|
||||
return await create_asset(db, user_id=user_id, project_id=project_id, payload=payload, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_ASSET_URI_PREFIX,
|
||||
PrivatePortraitAssetStatus,
|
||||
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
@@ -25,7 +26,6 @@ DOMAIN = "private_portrait"
|
||||
_ASSET_TYPE_TO_REFERENCE_TYPE = {
|
||||
PrivatePortraitAssetType.IMAGE.value: "image",
|
||||
PrivatePortraitAssetType.VIDEO.value: "video",
|
||||
PrivatePortraitAssetType.AUDIO.value: "audio",
|
||||
}
|
||||
|
||||
|
||||
@@ -260,24 +260,26 @@ async def resolve_private_portrait_references(
|
||||
|
||||
asset_id = str(_ref_get(ref, "private_asset_id") or "")
|
||||
if not asset_id:
|
||||
raise HTTPException(status_code=400, detail="真人素材引用缺少 private_asset_id")
|
||||
raise HTTPException(status_code=400, detail="私域人像素材引用缺少 private_asset_id")
|
||||
|
||||
asset = asset_map.get(asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=400, detail="真人素材不存在")
|
||||
raise HTTPException(status_code=400, detail="私域人像素材不存在")
|
||||
if asset.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="真人素材不属于当前用户")
|
||||
raise HTTPException(status_code=403, detail="私域人像素材不属于当前用户")
|
||||
if asset.deleted_at is not None:
|
||||
raise HTTPException(status_code=400, detail="真人素材已删除")
|
||||
raise HTTPException(status_code=400, detail="私域人像素材已删除")
|
||||
if asset.asset_type not in PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES:
|
||||
raise HTTPException(status_code=400, detail="Audio 暂未开放,当前仅支持 Image/Video 私域素材")
|
||||
if asset.status != PrivatePortraitAssetStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail=f"真人素材状态为 {asset.status},Active 后才可用于生成")
|
||||
raise HTTPException(status_code=400, detail=f"私域人像素材状态为 {asset.status},Active 后才可用于生成")
|
||||
if not asset.remote_asset_id:
|
||||
raise HTTPException(status_code=400, detail="真人素材缺少远程 AssetId")
|
||||
raise HTTPException(status_code=400, detail="私域人像素材缺少远程 AssetId")
|
||||
|
||||
expected_ref_type = _ASSET_TYPE_TO_REFERENCE_TYPE.get(asset.asset_type)
|
||||
ref_type = _normalize_ref_type(_ref_get(ref, "type"))
|
||||
if expected_ref_type and ref_type and ref_type != expected_ref_type:
|
||||
raise HTTPException(status_code=400, detail=f"真人素材类型不匹配:引用为 {ref_type},素材为 {expected_ref_type}")
|
||||
raise HTTPException(status_code=400, detail=f"私域人像素材类型不匹配:引用为 {ref_type},素材为 {expected_ref_type}")
|
||||
|
||||
provider_url = f"{PRIVATE_PORTRAIT_ASSET_URI_PREFIX}{asset.remote_asset_id}"
|
||||
_ref_set(ref, "source", PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from app.services.private_portrait.virtual.service import *
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
||||
PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE,
|
||||
PrivatePortraitAssetGroupStatus,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitProjectStatus,
|
||||
)
|
||||
from app.models.private_portrait import PrivatePortraitAssetGroup
|
||||
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitProjectUpdate, PrivatePortraitVirtualProjectCreate
|
||||
from app.services.operation_log_service import log_operation_error, log_operation_event
|
||||
from app.services.private_portrait.ark_client import ArkPrivateAssetClient
|
||||
from app.services.private_portrait.asset_service import create_asset
|
||||
from app.services.private_portrait.project_service import create_project, refresh_project_counters, update_project
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
DOMAIN = "private_portrait"
|
||||
|
||||
|
||||
def _json(data) -> str | None:
|
||||
if data is None:
|
||||
return None
|
||||
return json.dumps(data, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _remote_group_name(user_id: str, project_name: str) -> str:
|
||||
safe_name = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in project_name.strip())[:80]
|
||||
return f"virtual-{user_id}-{safe_name}"[:128]
|
||||
|
||||
|
||||
async def create_virtual_project(db: AsyncSession, *, user_id: str, payload: PrivatePortraitVirtualProjectCreate):
|
||||
project = await create_project(
|
||||
db,
|
||||
user_id=user_id,
|
||||
payload=payload, # type: ignore[arg-type]
|
||||
library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value,
|
||||
status=PrivatePortraitProjectStatus.CREATING_REMOTE_GROUP.value,
|
||||
remote_project_name=PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
||||
)
|
||||
remote_group_name = _remote_group_name(user_id, project.name)
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_GROUP_CREATE_REMOTE_START.value,
|
||||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
detail={"remote_group_name": remote_group_name, "remote_project_name": project.remote_project_name, "group_type": PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE},
|
||||
)
|
||||
try:
|
||||
resp = await ArkPrivateAssetClient().create_asset_group(
|
||||
project_name=project.remote_project_name,
|
||||
name=remote_group_name,
|
||||
description=project.description,
|
||||
group_type=PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE,
|
||||
)
|
||||
remote_group_id = resp.get("Id") or resp.get("GroupId") or resp.get("groupId")
|
||||
if not remote_group_id:
|
||||
raise RuntimeError("CreateAssetGroup 未返回素材组 ID")
|
||||
group = PrivatePortraitAssetGroup(
|
||||
id=generate_id(),
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value,
|
||||
remote_group_id=remote_group_id,
|
||||
remote_group_name=remote_group_name,
|
||||
remote_project_name=project.remote_project_name,
|
||||
group_type=PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE,
|
||||
status=PrivatePortraitAssetGroupStatus.ACTIVE.value,
|
||||
raw_response_json=_json(resp),
|
||||
)
|
||||
db.add(group)
|
||||
project.status = PrivatePortraitProjectStatus.ACTIVE.value
|
||||
await refresh_project_counters(db, [project.id])
|
||||
await db.flush()
|
||||
await db.refresh(project)
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_GROUP_CREATE_REMOTE_SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
group_id=group.id,
|
||||
detail={"remote_group_id": remote_group_id, "remote_group_name": remote_group_name, "remote_project_name": project.remote_project_name},
|
||||
)
|
||||
return project
|
||||
except Exception as exc:
|
||||
project.status = PrivatePortraitProjectStatus.CREATE_GROUP_FAILED.value
|
||||
await db.flush()
|
||||
log_operation_error(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_GROUP_CREATE_REMOTE_FAILED.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
exc=exc,
|
||||
)
|
||||
raise HTTPException(status_code=502, detail=f"创建火山虚拟人像素材组失败:{exc}") from exc
|
||||
|
||||
|
||||
async def update_virtual_project(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitProjectUpdate):
|
||||
project = await update_project(db, user_id=user_id, project_id=project_id, payload=payload, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
|
||||
# 远程同步失败不影响本地更新,记录日志便于排查。
|
||||
try:
|
||||
# 只同步当前激活组。
|
||||
from app.services.private_portrait.asset_service import get_project_active_group
|
||||
|
||||
group = await get_project_active_group(db, user_id=user_id, project_id=project_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
|
||||
await ArkPrivateAssetClient().update_asset_group(project_name=project.remote_project_name, group_id=group.remote_group_id, name=group.remote_group_name, title=project.name, description=project.description)
|
||||
except Exception as exc:
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_GROUP_UPDATE_REMOTE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, exc=exc)
|
||||
return project
|
||||
|
||||
|
||||
async def create_virtual_asset(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitAssetCreate):
|
||||
return await create_asset(db, user_id=user_id, project_id=project_id, payload=payload, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
|
||||
@@ -1,30 +0,0 @@
|
||||
# Debug Session: ratio-options-not-showing
|
||||
|
||||
## Session ID
|
||||
ratio-options-not-showing
|
||||
|
||||
## Created
|
||||
2026-07-01
|
||||
|
||||
## Symptom
|
||||
用户反馈:GenerateConver.tsx 中比例选项(ratioOptions)不显示,控制台无报错。
|
||||
|
||||
## Hypotheses (待验证假设)
|
||||
|
||||
1. **H1**: `ratioOptions` 默认值未生效 - useState 初始化失败
|
||||
2. **H2**: `getEngine()` 返回的 `data.engine.image` 不存在或为空数组,if 条件未进入
|
||||
3. **H3**: 比例按钮渲染区域被父容器 CSS 隐藏(如 `display: none`, `visibility: hidden`, `overflow: hidden`)
|
||||
4. **H4**: `ratioOptions` 在某处被重置为空数组
|
||||
5. **H5**: 组件条件渲染导致整个比例区域未挂载
|
||||
|
||||
## Evidence Points
|
||||
- EP1: 检查 `ratioOptions` 初始值是否为 8 个元素的数组
|
||||
- EP2: 检查 `getEngine()` 返回后 `data.engine.image` 是否存在
|
||||
- EP3: 检查渲染区域父容器的 CSS 是否有隐藏属性
|
||||
- EP4: 搜索代码中是否有 `setRatioOptions([])` 调用
|
||||
|
||||
## Status
|
||||
[OPEN] - 调试中
|
||||
|
||||
## Log File
|
||||
`trae-debug-log-ratio-options-not-showing.ndjson`
|
||||
|
||||
+114
-114
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-DUQKfJjB.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DjHXCPu7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -35,6 +35,7 @@ import CreativePlazaPage from './pages/CreativePlazaPage';
|
||||
import TeamManagementPage from './pages/TeamManagementPage';
|
||||
import JoinTeamPage from './pages/JoinTeamPage';
|
||||
import PrivatePortraitAuthorizeResult from './pages/PrivatePortraitAuthorizeResult';
|
||||
import PrivatePortraitVirtualMaterialPage from './pages/PrivatePortraitVirtualMaterialPage';
|
||||
import { useAuthStore } from './store/useAuthStore';
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { user, loading, checkAuth } = useAuthStore();
|
||||
@@ -124,6 +125,7 @@ const App = () => {
|
||||
<Route path="authorization" element={<AuthorizationPage />} />
|
||||
<Route path="authoriza-waiting" element={<AuthorizationWaitingPage />} />
|
||||
<Route path="materials" element={<MaterialListPage />} />
|
||||
<Route path="materials/private-portrait-virtual" element={<PrivatePortraitVirtualMaterialPage />} />
|
||||
<Route path="consume" element={<ConsumePage />} />
|
||||
<Route path="popular" element={<PopularPage />} />
|
||||
<Route path="authacc" element={<AuthAccountPage />} />
|
||||
|
||||
@@ -780,16 +780,25 @@ export async function getPrivatePortraitValidateSession(sessionId: string): Prom
|
||||
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 createPrivatePortraitAsset(projectId: string, payload: { url: string; assetType?: string; name?: string | null; videoDuration?: number | null; videoCoverUrl?: string | null; fileSize?: number | null; mimeType?: 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,
|
||||
video_duration: payload.videoDuration ?? null,
|
||||
video_cover_url: payload.videoCoverUrl || null,
|
||||
file_size: payload.fileSize ?? null,
|
||||
mime_type: payload.mimeType || null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPrivatePortraitAssets(projectId: string, params: { page?: number; pageSize?: number; status?: string; keyword?: string } = {}): Promise<PrivatePortraitAssetListOut> {
|
||||
export async function getPrivatePortraitAssets(projectId: string, params: { page?: number; pageSize?: number; status?: string; keyword?: string; assetType?: 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);
|
||||
if (params.assetType) query.set('asset_type', params.assetType);
|
||||
return api.get<PrivatePortraitAssetListOut>(`/private-portrait/projects/${projectId}/assets?${query.toString()}`);
|
||||
}
|
||||
|
||||
@@ -801,15 +810,90 @@ 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> {
|
||||
export async function getPrivatePortraitSelectableAssets(params: { projectId?: string; keyword?: string; page?: number; pageSize?: number; assetType?: string } = {}): 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);
|
||||
if (params.assetType) query.set('asset_type', params.assetType);
|
||||
return api.get<PrivatePortraitSelectableAssetListOut>(`/private-portrait/selectable-assets?${query.toString()}`);
|
||||
}
|
||||
|
||||
|
||||
// ── Private Portrait Virtual Library ─────────────────────
|
||||
export async function getPrivatePortraitVirtualConfig(): Promise<PrivatePortraitConfig> {
|
||||
return api.get<PrivatePortraitConfig>('/private-portrait/virtual/config');
|
||||
}
|
||||
|
||||
export async function getPrivatePortraitVirtualProjects(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/virtual-projects?${query.toString()}`);
|
||||
}
|
||||
|
||||
export async function createPrivatePortraitVirtualProject(payload: { name: string; description?: string | null }): Promise<PrivatePortraitProject> {
|
||||
return api.post<PrivatePortraitProject>('/private-portrait/virtual-projects', {
|
||||
name: payload.name,
|
||||
description: payload.description || null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updatePrivatePortraitVirtualProject(projectId: string, payload: { name?: string; description?: string | null; status?: string }): Promise<PrivatePortraitProject> {
|
||||
return api.put<PrivatePortraitProject>(`/private-portrait/virtual-projects/${projectId}`, payload);
|
||||
}
|
||||
|
||||
export async function deletePrivatePortraitVirtualProject(projectId: string): Promise<void> {
|
||||
await api.delete(`/private-portrait/virtual-projects/${projectId}`);
|
||||
}
|
||||
|
||||
export async function createPrivatePortraitVirtualAsset(projectId: string, payload: { url: string; assetType?: string; name?: string | null; videoDuration?: number | null; videoCoverUrl?: string | null; fileSize?: number | null; mimeType?: string | null }): Promise<PrivatePortraitAsset> {
|
||||
return api.post<PrivatePortraitAsset>(`/private-portrait/virtual-projects/${projectId}/assets`, {
|
||||
url: payload.url,
|
||||
asset_type: payload.assetType || 'Image',
|
||||
name: payload.name || null,
|
||||
video_duration: payload.videoDuration ?? null,
|
||||
video_cover_url: payload.videoCoverUrl || null,
|
||||
file_size: payload.fileSize ?? null,
|
||||
mime_type: payload.mimeType || null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPrivatePortraitVirtualAssets(projectId: string, params: { page?: number; pageSize?: number; status?: string; keyword?: string; assetType?: 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);
|
||||
if (params.assetType) query.set('asset_type', params.assetType);
|
||||
return api.get<PrivatePortraitAssetListOut>(`/private-portrait/virtual-projects/${projectId}/assets?${query.toString()}`);
|
||||
}
|
||||
|
||||
export async function getPrivatePortraitVirtualAsset(assetId: string): Promise<PrivatePortraitAsset> {
|
||||
return api.get<PrivatePortraitAsset>(`/private-portrait/virtual-assets/${assetId}`);
|
||||
}
|
||||
|
||||
export async function syncPrivatePortraitVirtualAsset(assetId: string): Promise<PrivatePortraitAsset> {
|
||||
return api.post<PrivatePortraitAsset>(`/private-portrait/virtual-assets/${assetId}/sync`);
|
||||
}
|
||||
|
||||
export async function deletePrivatePortraitVirtualAsset(assetId: string): Promise<void> {
|
||||
await api.delete(`/private-portrait/virtual-assets/${assetId}`);
|
||||
}
|
||||
|
||||
export async function getPrivatePortraitVirtualSelectableAssets(params: { projectId?: string; keyword?: string; page?: number; pageSize?: number; assetType?: string } = {}): 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);
|
||||
if (params.assetType) query.set('asset_type', params.assetType);
|
||||
return api.get<PrivatePortraitSelectableAssetListOut>(`/private-portrait/virtual-selectable-assets?${query.toString()}`);
|
||||
}
|
||||
|
||||
// ── Team Management APIs ──────────────────────────────
|
||||
export async function getManagedTeam(): Promise<any> {
|
||||
return api.get('/team/managed');
|
||||
|
||||
@@ -984,7 +984,7 @@ const AppLayout: React.FC = () => {
|
||||
placement="left"
|
||||
onClose={() => setMobileMenuOpen(false)}
|
||||
open={mobileMenuOpen}
|
||||
width={280}
|
||||
size={280}
|
||||
closable={true}
|
||||
className="mobile-menu-drawer"
|
||||
styles={{
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
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;
|
||||
|
||||
interface UploadSelectorProps {
|
||||
children: React.ReactNode;
|
||||
accept?: string;
|
||||
onLocalSelect?: (files: File[]) => void;
|
||||
onHistorySelect?: (items: any[]) => void;
|
||||
onPortraitSelect?: (items: any[]) => void;
|
||||
uploading?: boolean;
|
||||
tooltipTitle?: string;
|
||||
}
|
||||
|
||||
const UploadSelector: React.FC<UploadSelectorProps> = ({
|
||||
children,
|
||||
accept = 'image/*,video/*',
|
||||
onLocalSelect,
|
||||
onHistorySelect,
|
||||
onPortraitSelect,
|
||||
uploading,
|
||||
tooltipTitle,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleLocalSelect = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && onLocalSelect) {
|
||||
onLocalSelect(Array.from(files));
|
||||
}
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
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 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]
|
||||
);
|
||||
};
|
||||
|
||||
const confirmHistorySelection = () => {
|
||||
const items = mockHistoryData.filter(item => selectedHistoryItems.includes(item.id));
|
||||
onHistorySelect?.(items);
|
||||
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);
|
||||
};
|
||||
|
||||
const options = [
|
||||
{
|
||||
key: 'history',
|
||||
label: '历史记录',
|
||||
icon: <HistoryOutlined style={{ fontSize: 20, color: '#6366f1' }} />,
|
||||
description: '从历史上传记录中选择',
|
||||
onClick: () => {
|
||||
setModalVisible(false);
|
||||
setHistoryModalVisible(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'portrait',
|
||||
label: '人像',
|
||||
icon: <UserOutlined style={{ fontSize: 20, color: '#ec4899' }} />,
|
||||
description: '从人像库中选择',
|
||||
onClick: () => {
|
||||
setModalVisible(false);
|
||||
setPortraitModalVisible(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'local',
|
||||
label: '本地选取',
|
||||
icon: <FolderOpenOutlined style={{ fontSize: 20, color: '#10b981' }} />,
|
||||
description: '从本地电脑选择文件',
|
||||
onClick: () => {
|
||||
setModalVisible(false);
|
||||
handleLocalSelect();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
{tooltipTitle ? (
|
||||
<Tooltip title={tooltipTitle}>
|
||||
<div onClick={handleClick} style={{ cursor: 'pointer' }}>
|
||||
{children}
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div onClick={handleClick} style={{ cursor: 'pointer' }}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="选择上传来源"
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
footer={null}
|
||||
width={400}
|
||||
centered
|
||||
destroyOnHidden
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, paddingTop: 8 }}>
|
||||
{options.map((option) => (
|
||||
<div
|
||||
key={option.key}
|
||||
onClick={option.onClick}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
padding: '16px 20px',
|
||||
borderRadius: 12,
|
||||
background: '#f8fafc',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
border: '1px solid transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#fff';
|
||||
e.currentTarget.style.borderColor = '#e2e8f0';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.04)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#f8fafc';
|
||||
e.currentTarget.style.borderColor = 'transparent';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
>
|
||||
{option.icon}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: '#1e293b', marginBottom: 2 }}>
|
||||
{option.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#64748b' }}>
|
||||
{option.description}
|
||||
</div>
|
||||
</div>
|
||||
<PlusOutlined style={{ fontSize: 14, color: '#94a3b8' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="选择资产素材"
|
||||
open={historyModalVisible}
|
||||
onCancel={() => {
|
||||
setHistoryModalVisible(false);
|
||||
setSelectedHistoryItems([]);
|
||||
}}
|
||||
footer={null}
|
||||
width={"80%"}
|
||||
height={"50%"}
|
||||
centered
|
||||
>
|
||||
<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={[
|
||||
<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}
|
||||
style={{
|
||||
padding: '8px 24px',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
background: '#8b5cf6',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
color: '#fff',
|
||||
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>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadSelector;
|
||||
@@ -22,6 +22,8 @@ import bg2 from '../assets/bg2.png';
|
||||
import bg3 from '../assets/bg3.png';
|
||||
import text from '../assets/testb.png';
|
||||
|
||||
import UploadSelector from '../components/UploadSelector';
|
||||
|
||||
|
||||
|
||||
import {
|
||||
@@ -1257,6 +1259,8 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
if (isAudio) {
|
||||
try {
|
||||
audioDuration = await getAudioDuration(file);
|
||||
@@ -1293,7 +1297,6 @@ const AIChatPage: React.FC = () => {
|
||||
}];
|
||||
const labels = generateMediaLabels(newList);
|
||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||
message.success(`${isImage ? '图片' : (isAudio ? '音频' : '视频')}上传成功`);
|
||||
} catch (error) {
|
||||
message.error('上传失败');
|
||||
} finally {
|
||||
@@ -1303,6 +1306,118 @@ const AIChatPage: React.FC = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const doUpload = async (file: File): Promise<false | { name: string; type: 'image' | 'video' | 'audio'; url: string; label: string; duration?: number }> => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
const isAudio = file.type.startsWith('audio/');
|
||||
|
||||
if (!isImage && !isVideo && !isAudio) {
|
||||
message.error('仅支持图片、视频或音频文件');
|
||||
return false;
|
||||
}
|
||||
|
||||
const maxMB = isVideo ? 100 : (isAudio ? 50 : 10);
|
||||
if (file.size / 1024 / 1024 > maxMB) {
|
||||
message.error(`${isVideo ? '视频' : (isAudio ? '音频' : '图片')}大小不能超过${maxMB}MB`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAudio) {
|
||||
const audioExt = file.name.split('.').pop()?.toLowerCase();
|
||||
if (!['wav', 'mp3'].includes(audioExt || '')) {
|
||||
message.error('音频仅支持wav和mp3格式');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let videoDuration = 0;
|
||||
let audioDuration = 0;
|
||||
if (isVideo) {
|
||||
try {
|
||||
videoDuration = await getVideoDuration(file);
|
||||
if (videoDuration < 2) {
|
||||
message.error('视频素材最短不能少于 2 秒');
|
||||
return false;
|
||||
}
|
||||
const latestMedia = useAppStore.getState().currentMedia;
|
||||
const existingVideoDuration = latestMedia
|
||||
.filter((m) => m.type === 'video')
|
||||
.reduce((sum, m) => sum + (m.duration || 0), 0);
|
||||
if (existingVideoDuration + videoDuration > 15) {
|
||||
message.error(`所有视频素材总时长不能超过 15 秒,当前 ${(existingVideoDuration + videoDuration).toFixed(1)} 秒`);
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
message.error('无法获取视频信息,请检查文件是否损坏');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isAudio) {
|
||||
try {
|
||||
audioDuration = await getAudioDuration(file);
|
||||
if (audioDuration < 2) {
|
||||
message.error('音频素材最短不能少于 2 秒');
|
||||
return false;
|
||||
}
|
||||
const latestMedia = useAppStore.getState().currentMedia;
|
||||
const existingAudioDuration = latestMedia
|
||||
.filter((m) => m.type === 'audio')
|
||||
.reduce((sum, m) => sum + (m.duration || 0), 0);
|
||||
if (existingAudioDuration + audioDuration > 15) {
|
||||
message.error(`所有音频素材总时长不能超过 15 秒,当前 ${(existingAudioDuration + audioDuration).toFixed(1)} 秒`);
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
message.error('无法获取音频信息,请检查文件是否损坏');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const uploadFn = isImage ? uploadImage : (isAudio ? uploadAudio : uploadVideo);
|
||||
const res = await uploadFn(file);
|
||||
const mediaType: 'image' | 'video' | 'audio' = isImage ? 'image' : (isAudio ? 'audio' : 'video');
|
||||
return {
|
||||
name: file.name,
|
||||
type: mediaType,
|
||||
url: res.url,
|
||||
label: '',
|
||||
...(isVideo && { duration: videoDuration }),
|
||||
...(isAudio && { duration: audioDuration }),
|
||||
};
|
||||
} catch (error) {
|
||||
message.error('上传失败');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchUpload = async (files: File[]) => {
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
setUploading(true);
|
||||
|
||||
const result = await doUpload(file);
|
||||
|
||||
if (result) {
|
||||
const latestMedia = useAppStore.getState().currentMedia;
|
||||
const newList = [...latestMedia, result];
|
||||
const labels = generateMediaLabels(newList);
|
||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
message.success(`成功上传${successCount}个文件${failCount > 0 ? `,${failCount}个文件上传失败` : ''}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrivatePortraitAssetsSelected = (assets: PrivatePortraitSelectableAsset[]) => {
|
||||
if (mediaType !== 'video') {
|
||||
@@ -1346,12 +1461,12 @@ const AIChatPage: React.FC = () => {
|
||||
};
|
||||
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
// const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
// if (e.key === 'Enter' && !e.shiftKey) {
|
||||
// e.preventDefault();
|
||||
// handleSend();
|
||||
// }
|
||||
// };
|
||||
|
||||
// 检测光标前的 @ 符号
|
||||
const checkMention = (textarea: HTMLTextAreaElement, value: string) => {
|
||||
@@ -1511,7 +1626,7 @@ const AIChatPage: React.FC = () => {
|
||||
{/* 隐藏的音频播放器 */}
|
||||
<audio
|
||||
id="audio-player"
|
||||
src={playingAudioUrl || ''}
|
||||
src={playingAudioUrl || null}
|
||||
autoPlay
|
||||
onEnded={() => setPlayingAudioUrl(null)}
|
||||
style={{ display: 'none' }}
|
||||
@@ -2392,17 +2507,30 @@ const AIChatPage: React.FC = () => {
|
||||
>
|
||||
{/* 没有上传时的卡片样式 */}
|
||||
{currentMedia.length === 0 && (
|
||||
<Upload
|
||||
<UploadSelector
|
||||
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*,audio/*'}
|
||||
showUploadList={false}
|
||||
beforeUpload={handleUpload}
|
||||
>
|
||||
<Tooltip title={mediaType === 'image'
|
||||
onLocalSelect={handleBatchUpload}
|
||||
onHistorySelect={(items) => {
|
||||
const newMedia = items.map((item: any) => ({
|
||||
name: item.name,
|
||||
type: item.type as 'image' | 'video' | 'audio',
|
||||
url: '',
|
||||
label: '',
|
||||
}));
|
||||
setCurrentMedia([...currentMedia, ...newMedia]);
|
||||
message.success(`成功添加${items.length}个历史记录`);
|
||||
}}
|
||||
onPortraitSelect={(items) => {
|
||||
handlePrivatePortraitAssetsSelected(items as any);
|
||||
}}
|
||||
uploading={uploading}
|
||||
tooltipTitle={mediaType === 'image'
|
||||
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
|
||||
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount},
|
||||
视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}${maxAudio > 0 ? `,
|
||||
音频${currentMedia.filter(m => m.type === 'audio').length}/${maxAudio}` : ''}`
|
||||
}>
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 54,
|
||||
@@ -2445,10 +2573,9 @@ const AIChatPage: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Upload>
|
||||
</UploadSelector>
|
||||
)}
|
||||
{mediaType === 'video' && currentMedia.length === 0 && (
|
||||
{/* {mediaType === 'video' && currentMedia.length === 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setPrivateAssetPickerOpen(true)}
|
||||
@@ -2456,7 +2583,7 @@ const AIChatPage: React.FC = () => {
|
||||
>
|
||||
真人素材库
|
||||
</Button>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
{/* 层叠附件展示 - 鼠标移入向右排列展开 */}
|
||||
{currentMedia.length > 0 && (
|
||||
@@ -2560,17 +2687,30 @@ const AIChatPage: React.FC = () => {
|
||||
|
||||
{/* 右下角圆形+上传按钮 */}
|
||||
{currentMedia.length > 0 && (
|
||||
<Upload
|
||||
<UploadSelector
|
||||
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*,audio/*'}
|
||||
showUploadList={false}
|
||||
beforeUpload={handleUpload}
|
||||
>
|
||||
<Tooltip title={mediaType === 'image'
|
||||
onLocalSelect={handleBatchUpload}
|
||||
onHistorySelect={(items) => {
|
||||
const newMedia = items.map((item: any) => ({
|
||||
name: item.name,
|
||||
type: item.type as 'image' | 'video' | 'audio',
|
||||
url: '',
|
||||
label: '',
|
||||
}));
|
||||
setCurrentMedia([...currentMedia, ...newMedia]);
|
||||
message.success(`成功添加${items.length}个历史记录`);
|
||||
}}
|
||||
onPortraitSelect={(items) => {
|
||||
handlePrivatePortraitAssetsSelected(items as any);
|
||||
}}
|
||||
uploading={uploading}
|
||||
tooltipTitle={mediaType === 'image'
|
||||
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
|
||||
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount},
|
||||
视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}${maxAudio > 0 ? `,
|
||||
音频${currentMedia.filter(m => m.type === 'audio').length}/${maxAudio}` : ''}`
|
||||
}>
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -2600,10 +2740,9 @@ const AIChatPage: React.FC = () => {
|
||||
>
|
||||
<PlusOutlined style={{ fontSize: 14, color: '#8b5cf6', lineHeight: 1 }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Upload>
|
||||
</UploadSelector>
|
||||
)}
|
||||
{mediaType === 'video' && currentMedia.length > 0 && (
|
||||
{/* {mediaType === 'video' && currentMedia.length > 0 && (
|
||||
<Tooltip title="选择真人素材库">
|
||||
<div
|
||||
onClick={(e) => { e.stopPropagation(); setPrivateAssetPickerOpen(true); }}
|
||||
@@ -2630,7 +2769,7 @@ const AIChatPage: React.FC = () => {
|
||||
真
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -2665,7 +2804,23 @@ const AIChatPage: React.FC = () => {
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onKeyPress={handleKeyPress}
|
||||
onPaste={(e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const imageFiles: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
const file = items[i].getAsFile();
|
||||
if (file) imageFiles.push(file);
|
||||
}
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
imageFiles.forEach(async (file) => {
|
||||
await handleUpload(file);
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder={composerPlaceholder}
|
||||
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||
style={{
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
getRecordsPage,
|
||||
} from "../api";
|
||||
import { formatDate } from "../utils/formatDate";
|
||||
import UploadSelector from "../components/UploadSelector";
|
||||
import { generateUUID } from "../utils/uuid";
|
||||
|
||||
// const calcVideoCredits = (duration: number, resolution: Resolution): number => {
|
||||
@@ -260,10 +261,58 @@ const GeneratePage: React.FC = () => {
|
||||
const [creditRatios, setCreditRatios] = useState<any>([]);
|
||||
const [cimage, setCimage] = useState<any>([]);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const handlePasteUpload = async (file: File) => {
|
||||
const isImage = file.type.startsWith("image/");
|
||||
const isVideo = file.type.startsWith("video/");
|
||||
if (!isImage && !isVideo) {
|
||||
message.error("仅支持图片或视频文件");
|
||||
return false;
|
||||
}
|
||||
const maxMB = isVideo ? 100 : 10;
|
||||
if (file.size / 1024 / 1024 > maxMB) {
|
||||
message.error(
|
||||
`${isVideo ? "视频" : "图片"}大小不能超过${maxMB}MB`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const imageCount = references.filter(
|
||||
(r) => r.type === "image",
|
||||
).length;
|
||||
const videoCount = references.filter(
|
||||
(r) => r.type === "video",
|
||||
).length;
|
||||
if (isImage && imageCount >= 10) {
|
||||
message.error("最多上传10张图片");
|
||||
return false;
|
||||
}
|
||||
if (isVideo && videoCount >= 3) {
|
||||
message.error("最多上传3个视频");
|
||||
return false;
|
||||
}
|
||||
setUploading(true);
|
||||
const uploadFn = isImage ? uploadImage : uploadVideo;
|
||||
try {
|
||||
const res = await uploadFn(file);
|
||||
const typeLabel = isImage ? "图片" : "视频";
|
||||
const typeCount = isImage
|
||||
? imageCount + 1
|
||||
: videoCount + 1;
|
||||
setReferences((prev) => [
|
||||
...prev,
|
||||
{
|
||||
url: res.url,
|
||||
type: isImage ? "image" : "video",
|
||||
name: `${typeLabel}${typeCount}`,
|
||||
},
|
||||
]);
|
||||
message.success(`${typeLabel}上传成功`);
|
||||
} catch {
|
||||
message.error("上传失败");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 点击空白处关闭图片设置浮层
|
||||
useEffect(() => {
|
||||
@@ -1772,62 +1821,39 @@ const GeneratePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Upload
|
||||
<UploadSelector
|
||||
accept="image/*,video/*"
|
||||
showUploadList={false}
|
||||
multiple
|
||||
beforeUpload={(file) => {
|
||||
const isImage = file.type.startsWith("image/");
|
||||
const isVideo = file.type.startsWith("video/");
|
||||
if (!isImage && !isVideo) {
|
||||
message.error("仅支持图片或视频文件");
|
||||
return false;
|
||||
}
|
||||
const maxMB = isVideo ? 100 : 10;
|
||||
if (file.size / 1024 / 1024 > maxMB) {
|
||||
message.error(
|
||||
`${isVideo ? "视频" : "图片"}大小不能超过${maxMB}MB`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const imageCount = references.filter(
|
||||
(r) => r.type === "image",
|
||||
).length;
|
||||
const videoCount = references.filter(
|
||||
(r) => r.type === "video",
|
||||
).length;
|
||||
if (isImage && imageCount >= 10) {
|
||||
message.error("最多上传10张图片");
|
||||
return false;
|
||||
}
|
||||
if (isVideo && videoCount >= 3) {
|
||||
message.error("最多上传3个视频");
|
||||
return false;
|
||||
}
|
||||
setUploading(true);
|
||||
const uploadFn = isImage ? uploadImage : uploadVideo;
|
||||
uploadFn(file)
|
||||
.then((res) => {
|
||||
const typeLabel = isImage ? "图片" : "视频";
|
||||
const typeCount = isImage
|
||||
? imageCount + 1
|
||||
: videoCount + 1;
|
||||
setReferences((prev) => [
|
||||
...prev,
|
||||
{
|
||||
url: res.url,
|
||||
type: isImage ? "image" : "video",
|
||||
name: `${typeLabel}${typeCount}`,
|
||||
},
|
||||
]);
|
||||
message.success(`${typeLabel}上传成功`);
|
||||
})
|
||||
.catch(() => message.error("上传失败"))
|
||||
.finally(() => setUploading(false));
|
||||
return false;
|
||||
onLocalSelect={(files) => {
|
||||
files.forEach(async (file) => {
|
||||
await handlePasteUpload(file);
|
||||
});
|
||||
}}
|
||||
onHistorySelect={(items) => {
|
||||
items.forEach((item: any) => {
|
||||
setReferences(prev => [...prev, {
|
||||
url: '',
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
}]);
|
||||
});
|
||||
message.success(`成功添加${items.length}个历史记录`);
|
||||
}}
|
||||
onPortraitSelect={(items) => {
|
||||
items.forEach((item: any) => {
|
||||
setReferences(prev => [...prev, {
|
||||
url: item.previewUrl || '',
|
||||
type: 'image',
|
||||
name: item.name || '真人素材',
|
||||
source: 'private_portrait_asset',
|
||||
private_asset_id: item.id,
|
||||
label: '',
|
||||
}]);
|
||||
});
|
||||
message.success(`已添加 ${items.length} 个真人素材参考`);
|
||||
}}
|
||||
uploading={uploading}
|
||||
tooltipTitle={`参考内容(${references.length}/10)`}
|
||||
>
|
||||
<Tooltip title={`参考内容(${references.length}/10)`}>
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
@@ -1861,8 +1887,7 @@ const GeneratePage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Upload>
|
||||
</UploadSelector>
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
@@ -1885,6 +1910,23 @@ const GeneratePage: React.FC = () => {
|
||||
}
|
||||
setShowMention(false);
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const imageFiles: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
const file = items[i].getAsFile();
|
||||
if (file) imageFiles.push(file);
|
||||
}
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
imageFiles.forEach(async (file) => {
|
||||
await handlePasteUpload(file);
|
||||
});
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
placeholder="上传参考素材、输入文字,自由组合图、文多元素。输入 @ 可引用参考内容..."
|
||||
maxLength={500}
|
||||
@@ -4547,7 +4589,7 @@ const GeneratePage: React.FC = () => {
|
||||
footer={null}
|
||||
width={480}
|
||||
centered
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
closable={false}
|
||||
title={null}
|
||||
styles={{
|
||||
|
||||
@@ -14,9 +14,7 @@ import {
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getmedit ,getHomeCaseHeader,getHomeCaseButton} from '../api';
|
||||
|
||||
import hot from '../assets/homebtn1.png';
|
||||
import mashup from '../assets/homebtn2.png';
|
||||
import aicreate from '../assets/homebtn3.png';
|
||||
|
||||
|
||||
|
||||
// 把 ISO 时间格式化成 MM-DD HH:mm(与图片一致)
|
||||
@@ -45,6 +43,7 @@ const HomePage: React.FC = () => {
|
||||
const [caseAssets, setCaseAssets] = useState<any[]>([]);
|
||||
const [previewAsset, setPreviewAsset] = useState<any>(null);
|
||||
const previewVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const [activeContentTab, setActiveContentTab] = useState<'works' | 'cases'>('works');
|
||||
|
||||
useEffect(() => {
|
||||
getHomeCaseHeader().then((res: any) => {
|
||||
@@ -97,21 +96,28 @@ const HomePage: React.FC = () => {
|
||||
|
||||
const aiEntries = [
|
||||
{
|
||||
icon: <FileTextOutlined style={{ fontSize: 24, color: '#6366f1' }} />,
|
||||
icon: <FileTextOutlined style={{ fontSize: 24 }} />,
|
||||
title: '项目创建',
|
||||
description: '新建项目、设置图文视频参数、核对信息并生成素材',
|
||||
action: '立即创作',
|
||||
path: '/projects',
|
||||
},
|
||||
{
|
||||
icon: <FileTextOutlined style={{ fontSize: 24 }} />,
|
||||
title: '爆款复刻',
|
||||
description: '上传参考视频与产品图片,一键复刻爆款视频开头',
|
||||
action: '立即创作',
|
||||
path: '/initial',
|
||||
},
|
||||
{
|
||||
icon: <ScissorOutlined style={{ fontSize: 24, color: '#f97316' }} />,
|
||||
icon: <ScissorOutlined style={{ fontSize: 24 }} />,
|
||||
title: '拆镜复刻',
|
||||
description: '精细化镜头复刻工具,拆分参考视频单镜头独立复刻,提升素材原创度,规避素材同质化',
|
||||
action: '开始混剪',
|
||||
action: '开始拆镜',
|
||||
path: '/removelens',
|
||||
},
|
||||
{
|
||||
icon: <RobotOutlined style={{ fontSize: 24, color: '#10b981' }} />,
|
||||
icon: <RobotOutlined style={{ fontSize: 24 }} />,
|
||||
title: 'AI成片',
|
||||
description: '输入想法、剧本或上传参考,智能生成视频/图片',
|
||||
action: '立即生成',
|
||||
@@ -132,13 +138,6 @@ const HomePage: React.FC = () => {
|
||||
? mockVideos
|
||||
: mockVideos.filter(v => v.type === activeTab);
|
||||
|
||||
const materialCases = [
|
||||
'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=modern%20city%20skyline%20night%20view&image_size=landscape_16_9',
|
||||
'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=nature%20forest%20landscape%20sunlight&image_size=landscape_16_9',
|
||||
'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=abstract%20technology%20background%20digital&image_size=landscape_16_9',
|
||||
'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=food%20cooking%20kitchen%20delicious&image_size=landscape_16_9',
|
||||
'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=fashion%20clothing%20style%20elegant&image_size=landscape_16_9',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="content_box">
|
||||
@@ -172,7 +171,7 @@ const HomePage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
{/* ========== 顶部工作台引导区域(三步流程) ========== */}
|
||||
<div className="animate-fadeInUp" style={{
|
||||
{/* <div className="animate-fadeInUp" style={{
|
||||
padding: '24px 28px',
|
||||
borderRadius: 16,
|
||||
background: 'linear-gradient(135deg, #f0f9ff 0%, #faf5ff 50%, #fef3c7 100%)',
|
||||
@@ -181,7 +180,6 @@ const HomePage: React.FC = () => {
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* 区域标题 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18 }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
@@ -194,19 +192,6 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div >
|
||||
<span style={{
|
||||
cursor: 'pointer',
|
||||
fontSize: 12, color: '#1a50bbff', letterSpacing: 0.3,
|
||||
}}
|
||||
onClick={() => {
|
||||
navigate('/authorization')
|
||||
}}
|
||||
>如需进行账户素材推送 一键推送
|
||||
<ArrowRightOutlined style={{ marginLeft: 8, transform: 'rotate(0deg)' }} />
|
||||
|
||||
</span>
|
||||
</div> */}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -227,7 +212,6 @@ const HomePage: React.FC = () => {
|
||||
<div style={{ textAlign: 'center', fontSize: 20, fontWeight: 700, color: '#1e293b', marginBottom: 14, letterSpacing: 1 }}>
|
||||
第1步
|
||||
</div>
|
||||
{/* 插图占位:项目卡(标题/描述输入框 + 行业分类 chip) */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
minHeight: 140,
|
||||
@@ -240,21 +224,18 @@ const HomePage: React.FC = () => {
|
||||
gap: 8,
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
{/* 项目名称占位 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: 2, background: '#6366f1' }} />
|
||||
<div style={{ flex: 1, height: 22, background: '#fff', border: '1px solid #e2e8f0', borderRadius: 4, display: 'flex', alignItems: 'center', padding: '0 8px', fontSize: 10, color: '#94a3b8' }}>
|
||||
项目名称...
|
||||
</div>
|
||||
</div>
|
||||
{/* 行业分类 chip */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
<div style={{ padding: '2px 8px', background: '#eef2ff', color: '#6366f1', borderRadius: 10, fontSize: 10, fontWeight: 500, border: '1px solid #c7d2fe' }}>美妆</div>
|
||||
<div style={{ padding: '2px 8px', background: '#fff7ed', color: '#f97316', borderRadius: 10, fontSize: 10, fontWeight: 500, border: '1px solid #fed7aa' }}>美食</div>
|
||||
<div style={{ padding: '2px 8px', background: '#ecfdf5', color: '#10b981', borderRadius: 10, fontSize: 10, fontWeight: 500, border: '1px solid #a7f3d0' }}>3C数码</div>
|
||||
<div style={{ padding: '2px 8px', background: '#f5f3ff', color: '#8b5cf6', borderRadius: 10, fontSize: 10, fontWeight: 500, border: '1px solid #ddd6fe' }}>服饰</div>
|
||||
</div>
|
||||
{/* 描述占位行 */}
|
||||
<div style={{ height: 16, background: '#fff', border: '1px solid #e2e8f0', borderRadius: 4 }} />
|
||||
<div style={{ height: 16, width: '70%', background: '#fff', border: '1px solid #e2e8f0', borderRadius: 4 }} />
|
||||
</div>
|
||||
@@ -287,7 +268,6 @@ const HomePage: React.FC = () => {
|
||||
<div style={{ textAlign: 'center', fontSize: 20, fontWeight: 700, color: '#1e293b', marginBottom: 14, letterSpacing: 1 }}>
|
||||
第2步
|
||||
</div>
|
||||
{/* 插图占位:图片/视频切换 + 尺寸/时长参数 */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
minHeight: 140,
|
||||
@@ -300,7 +280,6 @@ const HomePage: React.FC = () => {
|
||||
gap: 8,
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
{/* 图片 / 视频 切换 */}
|
||||
<div style={{ display: 'flex', background: '#f1f5f9', borderRadius: 6, padding: 2, gap: 2 }}>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, padding: '5px 0', background: '#fff', borderRadius: 4, fontSize: 11, fontWeight: 600, color: '#6366f1', boxShadow: '0 1px 3px rgba(99,102,241,0.15)' }}>
|
||||
<VideoCameraOutlined style={{ fontSize: 11 }} />视频
|
||||
@@ -309,7 +288,6 @@ const HomePage: React.FC = () => {
|
||||
<PictureOutlined style={{ fontSize: 11 }} /> 图片
|
||||
</div>
|
||||
</div>
|
||||
{/* 尺寸参数 */}
|
||||
<div>
|
||||
<div style={{ fontSize: 9, color: '#94a3b8', marginBottom: 3 }}>尺寸比例</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
@@ -318,7 +296,6 @@ const HomePage: React.FC = () => {
|
||||
<div style={{ flex: 1, textAlign: 'center', padding: '4px 0', background: '#fff', color: '#64748b', border: '1px solid #e2e8f0', borderRadius: 4, fontSize: 10 }}>1:1</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 时长参数 */}
|
||||
<div>
|
||||
<div style={{ fontSize: 9, color: '#94a3b8', marginBottom: 3 }}>时长</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
@@ -357,7 +334,6 @@ const HomePage: React.FC = () => {
|
||||
<div style={{ textAlign: 'center', fontSize: 20, fontWeight: 700, color: '#1e293b', marginBottom: 14, letterSpacing: 1 }}>
|
||||
第3步
|
||||
</div>
|
||||
{/* 插图占位:核对清单(✓ 项)+ 一键生成按钮 */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
minHeight: 140,
|
||||
@@ -380,12 +356,7 @@ const HomePage: React.FC = () => {
|
||||
<div style={{ width: 14, height: 14, borderRadius: '50%', background: '#10b981', color: '#fff', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700 }}>✓</div>
|
||||
<div style={{ fontSize: 10, color: '#065f46', fontWeight: 500 }}>尺寸 9:16 · 时长 5s</div>
|
||||
</div>
|
||||
{/* <div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', background: '#fff7ed', border: '1px solid #fed7aa', borderRadius: 5 }}>
|
||||
<div style={{ width: 14, height: 14, borderRadius: '50%', background: '#f97316', color: '#fff', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700 }}>!</div>
|
||||
<div style={{ fontSize: 10, color: '#9a3412', fontWeight: 500 }}>参考素材 0/3</div>
|
||||
</div> */}
|
||||
</div>
|
||||
{/* 一键生成按钮 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -450,7 +421,6 @@ const HomePage: React.FC = () => {
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* 高光层(hover 时轻微变亮) */}
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
@@ -466,11 +436,11 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* ========== AI 创作入口区域 ========== */}
|
||||
<div className="animate-fadeInUp stagger-children" style={{
|
||||
padding: '24px 28px',
|
||||
<div className="animate-fadeInUp" style={{
|
||||
padding: '20px',
|
||||
borderRadius: 16,
|
||||
background: '#fff',
|
||||
border: '1px solid #e2e8f0',
|
||||
@@ -480,7 +450,7 @@ const HomePage: React.FC = () => {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 18,
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 4, height: 18, borderRadius: 2,
|
||||
@@ -494,13 +464,13 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
|
||||
{aiEntries.map((entry, index) => {
|
||||
// 三个入口用三种不同色调的渐变光晕作为视觉区分,但都保持白底卡片
|
||||
const accentMap = [
|
||||
{ color: '#6366f1', light: 'rgba(99,102,241,0.10)', tag: '复刻', bg: hot },
|
||||
{ color: '#f97316', light: 'rgba(249,115,22,0.10)', tag: '混剪', bg: mashup },
|
||||
{ color: '#10b981', light: 'rgba(16,185,129,0.10)', tag: '云创', bg: aicreate },
|
||||
{ color: '#3b82f6', light: 'rgba(59,130,246,0.12)', tag: '项目' },
|
||||
{ color: '#6366f1', light: 'rgba(99,102,241,0.12)', tag: '复刻' },
|
||||
{ color: '#f97316', light: 'rgba(249,115,22,0.12)', tag: '拆镜' },
|
||||
{ color: '#10b981', light: 'rgba(16,185,129,0.12)', tag: '云创' },
|
||||
];
|
||||
const accent = accentMap[index] || accentMap[0];
|
||||
return (
|
||||
@@ -509,58 +479,71 @@ const HomePage: React.FC = () => {
|
||||
onClick={() => navigate(entry.path)}
|
||||
className="project-card"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '20px 22px',
|
||||
borderRadius: 14,
|
||||
flex: '1',
|
||||
minWidth: 260,
|
||||
height: 120,
|
||||
padding: '16px',
|
||||
borderRadius: 16,
|
||||
background: '#fff',
|
||||
border: '1px solid #e2e8f0',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transition: 'all 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
backgroundImage: `url(${accent.bg})`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: '100% 100%',
|
||||
backgroundPosition: 'center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = accent.color;
|
||||
e.currentTarget.style.boxShadow = `0 12px 32px ${accent.light}`;
|
||||
e.currentTarget.style.boxShadow = `0 8px 24px ${accent.light}`;
|
||||
e.currentTarget.style.background = accent.light;
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#e2e8f0';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
e.currentTarget.style.background = '#fff';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
{/* 顶部装饰光带 */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0, left: 0, right: 0, height: 3,
|
||||
background: `linear-gradient(90deg, ${accent.color}, ${accent.color}88)`,
|
||||
}} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div style={{
|
||||
<div
|
||||
style={{
|
||||
width: 48, height: 48,
|
||||
borderRadius: 12,
|
||||
background: accent.light,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<span style={{ color: accent.color, fontSize: 22, display: 'flex' }}>{entry.icon}</span>
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.3s ease',
|
||||
color: accent.color,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = accent.color;
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = accent.light;
|
||||
e.currentTarget.style.color = accent.color;
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 20, display: 'flex', transition: 'all 0.3s ease' }}>{entry.icon}</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 11, color: accent.color,
|
||||
padding: '2px 8px', borderRadius: 6,
|
||||
background: accent.light, fontWeight: 600,
|
||||
}}>{accent.tag}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: '#1f2937', marginBottom: 4 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{entry.title}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 10, color: accent.color,
|
||||
padding: '2px 7px', borderRadius: 4,
|
||||
background: accent.light, fontWeight: 600,
|
||||
}}>{accent.tag}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#6b7280', marginBottom: 14, lineHeight: 1.5, minHeight: 36 }}>
|
||||
<div style={{ fontSize: 12, color: '#64748b', }}>
|
||||
{entry.description}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -568,6 +551,7 @@ const HomePage: React.FC = () => {
|
||||
color: accent.color,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{entry.action}
|
||||
<ArrowRightOutlined style={{ fontSize: 12 }} />
|
||||
@@ -578,27 +562,57 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== 近期作品区域 ========== */}
|
||||
{/* ========== 作品与案例区域 ========== */}
|
||||
<div className="animate-fadeInUp" style={{
|
||||
padding: '24px 28px',
|
||||
borderRadius: 16,
|
||||
background: '#fff',
|
||||
border: '1px solid #e2e8f0',
|
||||
marginBottom: 20,
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{/* <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{
|
||||
width: 4, height: 18, borderRadius: 2,
|
||||
background: 'linear-gradient(180deg, #6366f1, #a855f7)',
|
||||
}} />
|
||||
<div style={{ fontSize: 17, fontWeight: 700, color: '#1f2937', letterSpacing: 0.3 }}>
|
||||
近期作品
|
||||
{activeContentTab === 'works' ? '近期作品' : '素材案例'}
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* 外层Tab切换:近期作品 / 素材案例 */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{[
|
||||
{ key: 'works', label: '近期作品' },
|
||||
{ key: 'cases', label: '素材案例' },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => setActiveContentTab(item.key as 'works' | 'cases')}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.25s ease',
|
||||
background: activeContentTab === item.key
|
||||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||||
: '#f1f5f9',
|
||||
color: activeContentTab === item.key ? '#fff' : '#64748b',
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/* Tab切换 */}
|
||||
{/* 内容区域 */}
|
||||
{activeContentTab === 'works' ? (
|
||||
<>
|
||||
{/* 近期作品子Tab */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
@@ -611,7 +625,7 @@ const HomePage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 视频网格 */}
|
||||
{/* 近期作品网格 */}
|
||||
<div className="stagger-children" style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 16 }}>
|
||||
{filteredVideos.length === 0 ? (
|
||||
<div style={{
|
||||
@@ -629,10 +643,6 @@ const HomePage: React.FC = () => {
|
||||
key={video.id || `${video.type}-${Math.random()}`}
|
||||
className="project-card"
|
||||
onClick={() => {
|
||||
// 按模块分发跳转:
|
||||
// - 爆款复刻(hot)→ 复刻详情页
|
||||
// - AI 成片(ai)→ 对话/生成页
|
||||
// - 项目记录(project)→ 项目详情页
|
||||
const id = video.moduleProjectId;
|
||||
if (video.type === 'hotOpeningReplicate' && id != null) {
|
||||
navigate(`/initial/${id}/initialinfo`);
|
||||
@@ -655,28 +665,15 @@ const HomePage: React.FC = () => {
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
aspectRatio: '16/9',
|
||||
// background: '#1a1a2e',
|
||||
}}>
|
||||
{(() => {
|
||||
// 1) 读取后端 API 基础地址;环境变量未配置时降级到本地 8000
|
||||
const apiBase = (import.meta.env.VITE_API_BASE as string) || 'http://localhost:8000';
|
||||
|
||||
// 2) 判断当前作品是否为"图片":
|
||||
// - 爆款复刻(type === 'hot')始终是视频,不参与图片判断
|
||||
// - 其他模块(项目记录 / AI 成片)根据 genType 判定
|
||||
// - genType 可能是字符串 'image',也可能是数字 1(兼容两种后端约定)
|
||||
const isImage = video.type !== 'hotOpeningReplicate'
|
||||
&& (
|
||||
String(video.resourceType ?? '').toLowerCase() === 'image'
|
||||
|| video.resourceType === 1
|
||||
|| String(video.resourceType ?? '') === '1'
|
||||
);
|
||||
|
||||
// 3) 根据媒体类型选择对应的资源路径:
|
||||
// - 图片:后端返回的 imageUrl 已经是带签名的完整相对路径
|
||||
// 形如 /static/generate/images/2026/06/26/0019f017f5924df4123.png?exp=...&sign=...&w=300&p=50
|
||||
// - 视频:使用视频封面 videoCoverUrl(这是视频作品的静态缩略图)
|
||||
// - 爆款复刻(type === 'hot')特殊处理:使用 finalVideoCoverUrl
|
||||
let rawPath = '';
|
||||
if (isImage) {
|
||||
rawPath = '/static' + video.resultUrl + '&w=300&p=50' || '';
|
||||
@@ -685,15 +682,9 @@ const HomePage: React.FC = () => {
|
||||
} else {
|
||||
rawPath = video.coverUrl || video.resultUrl || video.resultUrl || '';
|
||||
}
|
||||
|
||||
// 4) 拼装最终 src:
|
||||
// - rawPath 为空 → 用空串(让 <img> 走 onError 兜底)
|
||||
// - 已经是 http(s) 完整 URL → 直接使用(OSS / CDN 场景)
|
||||
// - 否则视为后端相对路径,前面拼 apiBase
|
||||
const src = rawPath
|
||||
? (rawPath.startsWith('http') ? rawPath : apiBase + rawPath)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
@@ -733,7 +724,6 @@ const HomePage: React.FC = () => {
|
||||
color: '#64748b',
|
||||
}}>
|
||||
{(() => {
|
||||
// 显示所属模块,而非媒体类型
|
||||
const moduleMap: Record<string, string> = {
|
||||
project: '项目媒体',
|
||||
chatAi: 'AI成片',
|
||||
@@ -765,47 +755,10 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
{/* ========== 素材案例区域 ========== */}
|
||||
{caseAssets.length > 0 && (
|
||||
<div className="animate-fadeInUp" style={{
|
||||
padding: '24px 28px',
|
||||
borderRadius: 16,
|
||||
background: '#fff',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 18,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{
|
||||
width: 4, height: 18, borderRadius: 2,
|
||||
background: 'linear-gradient(180deg, #6366f1, #a855f7)',
|
||||
}} />
|
||||
<div style={{ fontSize: 17, fontWeight: 700, color: '#1f2937', letterSpacing: 0.3 }}>
|
||||
素材案例
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#94a3b8', marginLeft: 4 }}>
|
||||
精选优质作品参考
|
||||
</div>
|
||||
</div>
|
||||
{/* <div style={{
|
||||
fontSize: 13, color: '#6366f1', cursor: 'pointer', fontWeight: 500,
|
||||
display: 'flex', alignItems: 'center', gap: 2,
|
||||
}}>
|
||||
更多案例
|
||||
<ArrowRightOutlined style={{ fontSize: 11 }} />
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{/* Tab切换 */}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 素材案例子Tab */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Tabs
|
||||
activeKey={activeCaseTab}
|
||||
@@ -827,7 +780,7 @@ const HomePage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ========== 素材案例列表(与近期作品一致) ========== */}
|
||||
{/* 素材案例网格 */}
|
||||
<div className="stagger-children" style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 16 }}>
|
||||
{caseAssets.length === 0 ? (
|
||||
<div style={{
|
||||
@@ -853,7 +806,6 @@ const HomePage: React.FC = () => {
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
{/* 媒体区域 16:9 */}
|
||||
<div style={{ position: 'relative', aspectRatio: '16/9', }}>
|
||||
{asset.mediaType === 'video' ? (
|
||||
<>
|
||||
@@ -882,7 +834,6 @@ const HomePage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 底部信息栏 */}
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
background: '#f8fafc',
|
||||
@@ -901,8 +852,9 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ========== 预览弹窗 ========== */}
|
||||
<Modal
|
||||
|
||||
@@ -449,7 +449,10 @@ const GenerateConver: React.FC = () => {
|
||||
border: '1px solid rgba(99, 102, 241, 0.08)',
|
||||
position: 'relative', overflow: 'hidden', flexWrap: 'wrap', gap: 12,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 ,
|
||||
paddingBottom: 12,
|
||||
|
||||
}}>
|
||||
{/* <div style={{ width: 32, height: 2, background: 'linear-gradient(90deg, transparent, #6366f1, #8b5cf6, transparent)', borderRadius: 1 }} /> */}
|
||||
<div>
|
||||
<h2 style={{
|
||||
|
||||
@@ -90,9 +90,6 @@ const JoinTeamPage: React.FC = () => {
|
||||
extra={
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => navigate('/projects')}>返回首页</Button>
|
||||
{user && (
|
||||
<Button onClick={() => navigate('/team-management')}>团队管理</Button>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
@@ -140,7 +137,6 @@ const JoinTeamPage: React.FC = () => {
|
||||
extra={
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => navigate('/projects')}>返回首页</Button>
|
||||
<Button onClick={() => navigate('/team-management')}>团队管理</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -295,9 +295,8 @@
|
||||
|
||||
.login-code-btn {
|
||||
height: 48px !important;
|
||||
border-radius: 0 10px 10px 0 !important;
|
||||
border-radius: 10 !important;
|
||||
border: 1.5px solid #e2e8f0 !important;
|
||||
border-left: none !important;
|
||||
font-weight: 600 !important;
|
||||
min-width: 100px !important;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Table, Tag, Input, Pagination, Typography, Select, App, Modal } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FolderOpenOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { FolderOpenOutlined, EyeOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import { getResourcesMaterialList, getPreTestList, submitPreTest, getDefaultPreTest } from '../api';
|
||||
import PreResultDisplay from '../components/PreResultDisplay';
|
||||
|
||||
@@ -436,6 +436,19 @@ const MaterialListPage: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
onClick={() => navigate('/materials/private-portrait-virtual')}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
fontSize: 14,
|
||||
borderColor: '#8b5cf6',
|
||||
color: '#7c3aed',
|
||||
background: '#f5f3ff',
|
||||
}}
|
||||
>
|
||||
私域虚拟人像库
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={pushTemplatesLoading}
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Upload,
|
||||
} from 'antd';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CloudSyncOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
PictureOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
UploadOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
createPrivatePortraitVirtualAsset,
|
||||
createPrivatePortraitVirtualProject,
|
||||
deletePrivatePortraitVirtualAsset,
|
||||
deletePrivatePortraitVirtualProject,
|
||||
getPrivatePortraitVirtualAssets,
|
||||
getPrivatePortraitVirtualConfig,
|
||||
getPrivatePortraitVirtualProjects,
|
||||
syncPrivatePortraitVirtualAsset,
|
||||
uploadImage,
|
||||
uploadVideo,
|
||||
} from '../api';
|
||||
import type { PrivatePortraitAsset, PrivatePortraitConfig, PrivatePortraitProject } from '../types';
|
||||
|
||||
const { Text, Title, Paragraph } = Typography;
|
||||
|
||||
type AssetTypeFilter = 'Image' | 'Video' | undefined;
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||
creating: { label: '本地创建中', color: 'processing' },
|
||||
Processing: { label: '火山处理中', color: 'processing' },
|
||||
Active: { label: '可用于生成', color: 'success' },
|
||||
Failed: { label: '入库失败', color: 'error' },
|
||||
local_deleted: { label: '本地已删', color: 'default' },
|
||||
remote_deleted: { label: '远端已删', color: 'default' },
|
||||
delete_failed: { label: '远端删除失败', color: 'error' },
|
||||
};
|
||||
|
||||
const assetTypeConfig: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
|
||||
Image: { label: '图片', color: 'green', icon: <PictureOutlined /> },
|
||||
Video: { label: '视频', color: 'blue', icon: <VideoCameraOutlined /> },
|
||||
};
|
||||
|
||||
const formatDateTime = (dateStr?: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
const formatSize = (size?: number | null) => {
|
||||
const value = Number(size || 0);
|
||||
if (!value) return '-';
|
||||
if (value >= 1024 * 1024 * 1024) return `${(value / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(2)} MB`;
|
||||
if (value >= 1024) return `${(value / 1024).toFixed(2)} KB`;
|
||||
return `${value} B`;
|
||||
};
|
||||
|
||||
const buildPreviewUrl = (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 getAssetPreviewUrl = (asset: PrivatePortraitAsset) => {
|
||||
return buildPreviewUrl(asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.remoteUrl || asset.sourceUrl);
|
||||
};
|
||||
|
||||
const guessAssetType = (file?: File | null): 'Image' | 'Video' => {
|
||||
if (!file) return 'Image';
|
||||
if (file.type.startsWith('video/')) return 'Video';
|
||||
const name = file.name.toLowerCase();
|
||||
if (/\.(mp4|mov|webm|m4v|avi|mkv)$/.test(name)) return 'Video';
|
||||
return 'Image';
|
||||
};
|
||||
|
||||
const getVideoDuration = (file: File): Promise<number | null> => {
|
||||
return new Promise((resolve) => {
|
||||
if (!file.type.startsWith('video/')) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.onloadedmetadata = () => {
|
||||
const duration = Number.isFinite(video.duration) ? video.duration : null;
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(duration);
|
||||
};
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(null);
|
||||
};
|
||||
video.src = url;
|
||||
});
|
||||
};
|
||||
|
||||
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
|
||||
const value = status || '-';
|
||||
const config = statusConfig[value];
|
||||
return <Tag color={config?.color || 'default'}>{config?.label || value}</Tag>;
|
||||
};
|
||||
|
||||
const TypeTag: React.FC<{ type?: string | null }> = ({ type }) => {
|
||||
const value = type || '-';
|
||||
const config = assetTypeConfig[value];
|
||||
return <Tag color={config?.color || 'default'} icon={config?.icon}>{config?.label || value}</Tag>;
|
||||
};
|
||||
|
||||
const PrivatePortraitVirtualMaterialPage: React.FC = () => {
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const [config, setConfig] = useState<PrivatePortraitConfig | null>(null);
|
||||
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>();
|
||||
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
|
||||
const [projectLoading, setProjectLoading] = useState(false);
|
||||
const [assetLoading, setAssetLoading] = useState(false);
|
||||
const [assetPage, setAssetPage] = useState(1);
|
||||
const [assetPageSize, setAssetPageSize] = useState(20);
|
||||
const [assetTotal, setAssetTotal] = useState(0);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [assetStatus, setAssetStatus] = useState<string>();
|
||||
const [assetType, setAssetType] = useState<AssetTypeFilter>();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [creatingProject, setCreatingProject] = useState(false);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [assetName, setAssetName] = useState('');
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState('');
|
||||
const [previewType, setPreviewType] = useState<'Image' | 'Video'>('Image');
|
||||
const [createForm] = Form.useForm<{ name: string; description?: string }>();
|
||||
|
||||
const selectedProject = useMemo(
|
||||
() => projects.find((item) => item.id === selectedProjectId) || null,
|
||||
[projects, selectedProjectId],
|
||||
);
|
||||
|
||||
const quotaText = useMemo(() => {
|
||||
if (!config) return '额度加载中';
|
||||
return `已用 ${config.usedAssetCount || 0} / ${config.assetLimit || 0} 个素材,剩余 ${config.remainingAssetCount || 0}`;
|
||||
}, [config]);
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const next = await getPrivatePortraitVirtualConfig();
|
||||
setConfig(next);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载私域素材额度失败');
|
||||
}
|
||||
};
|
||||
|
||||
const loadProjects = async () => {
|
||||
setProjectLoading(true);
|
||||
try {
|
||||
const res = await getPrivatePortraitVirtualProjects({ page: 1, pageSize: 100, status: 'active' });
|
||||
const next = res.items || [];
|
||||
setProjects(next);
|
||||
setSelectedProjectId((prev) => prev && next.some((item) => item.id === prev) ? prev : next[0]?.id);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载虚拟人像项目组失败');
|
||||
} finally {
|
||||
setProjectLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAssets = async (page = assetPage, pageSize = assetPageSize) => {
|
||||
if (!selectedProjectId) {
|
||||
setAssets([]);
|
||||
setAssetTotal(0);
|
||||
return;
|
||||
}
|
||||
setAssetLoading(true);
|
||||
try {
|
||||
const res = await getPrivatePortraitVirtualAssets(selectedProjectId, {
|
||||
page,
|
||||
pageSize,
|
||||
keyword: keyword.trim() || undefined,
|
||||
status: assetStatus,
|
||||
assetType,
|
||||
});
|
||||
setAssets(res.items || []);
|
||||
setAssetTotal(res.total || 0);
|
||||
setAssetPage(page);
|
||||
setAssetPageSize(pageSize);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载虚拟人像素材失败');
|
||||
} finally {
|
||||
setAssetLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reloadAll = async () => {
|
||||
await Promise.all([loadConfig(), loadProjects()]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reloadAll();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProjectId) loadAssets(1, assetPageSize);
|
||||
}, [selectedProjectId]);
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
const values = await createForm.validateFields();
|
||||
setCreatingProject(true);
|
||||
try {
|
||||
const project = await createPrivatePortraitVirtualProject(values);
|
||||
message.success('虚拟人像项目组已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
await loadProjects();
|
||||
setSelectedProjectId(project.id);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '创建项目组失败');
|
||||
} finally {
|
||||
setCreatingProject(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedProjectId) {
|
||||
message.warning('请先创建或选择项目组');
|
||||
return;
|
||||
}
|
||||
const file = fileList[0]?.originFileObj as File | undefined;
|
||||
if (!file) {
|
||||
message.warning('请先选择图片或视频素材');
|
||||
return;
|
||||
}
|
||||
const currentType = guessAssetType(file);
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded = currentType === 'Video' ? await uploadVideo(file) : await uploadImage(file);
|
||||
const duration = currentType === 'Video' ? await getVideoDuration(file) : null;
|
||||
await createPrivatePortraitVirtualAsset(selectedProjectId, {
|
||||
url: uploaded.url,
|
||||
assetType: currentType,
|
||||
name: assetName.trim() || file.name,
|
||||
videoDuration: duration,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type || null,
|
||||
});
|
||||
message.success(currentType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中');
|
||||
setUploadOpen(false);
|
||||
setFileList([]);
|
||||
setAssetName('');
|
||||
await Promise.all([loadConfig(), loadProjects(), loadAssets(1, assetPageSize)]);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '上传素材失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncAsset = async (assetId: string) => {
|
||||
try {
|
||||
await syncPrivatePortraitVirtualAsset(assetId);
|
||||
message.success('素材状态已刷新');
|
||||
await Promise.all([loadConfig(), loadProjects(), loadAssets(assetPage, assetPageSize)]);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '刷新素材状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAsset = async (assetId: string) => {
|
||||
try {
|
||||
await deletePrivatePortraitVirtualAsset(assetId);
|
||||
message.success('素材已删除,远端删除将异步执行');
|
||||
await Promise.all([loadConfig(), loadProjects(), loadAssets(assetPage, assetPageSize)]);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '删除素材失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
if (!selectedProjectId) return;
|
||||
try {
|
||||
await deletePrivatePortraitVirtualProject(selectedProjectId);
|
||||
message.success('项目组已删除,远端资产组将异步删除');
|
||||
setSelectedProjectId(undefined);
|
||||
await reloadAll();
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '删除项目组失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openPreview = (asset: PrivatePortraitAsset) => {
|
||||
const url = getAssetPreviewUrl(asset);
|
||||
if (!url) {
|
||||
message.warning('暂无可预览地址');
|
||||
return;
|
||||
}
|
||||
setPreviewUrl(url);
|
||||
setPreviewType(asset.assetType === 'Video' ? 'Video' : 'Image');
|
||||
setPreviewOpen(true);
|
||||
};
|
||||
|
||||
const renderAssetCard = (asset: PrivatePortraitAsset) => {
|
||||
const preview = getAssetPreviewUrl(asset);
|
||||
const isVideo = asset.assetType === 'Video';
|
||||
return (
|
||||
<Card
|
||||
key={asset.id}
|
||||
hoverable
|
||||
bodyStyle={{ padding: 12 }}
|
||||
style={{ borderRadius: 16, overflow: 'hidden', borderColor: '#eef2f7' }}
|
||||
cover={(
|
||||
<div style={{ height: 170, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
|
||||
{preview && !isVideo ? (
|
||||
<img src={preview} alt={asset.name || '虚拟人像素材'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : preview && isVideo && asset.videoCoverUrl ? (
|
||||
<img src={preview} alt={asset.name || '视频封面'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : isVideo ? (
|
||||
<VideoCameraOutlined style={{ fontSize: 42, color: '#64748b' }} />
|
||||
) : (
|
||||
<PictureOutlined style={{ fontSize: 42, color: '#64748b' }} />
|
||||
)}
|
||||
{isVideo && <Tag color="blue" style={{ position: 'absolute', left: 10, top: 10 }}>视频</Tag>}
|
||||
<Button size="small" shape="circle" icon={<EyeOutlined />} style={{ position: 'absolute', right: 10, top: 10 }} onClick={() => openPreview(asset)} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Tooltip title={asset.name || asset.remoteAssetId || asset.id}>
|
||||
<Text strong ellipsis style={{ display: 'block' }}>{asset.name || asset.remoteAssetId || '未命名素材'}</Text>
|
||||
</Tooltip>
|
||||
<Space wrap size={4}>
|
||||
<TypeTag type={asset.assetType} />
|
||||
<StatusTag status={asset.status} />
|
||||
</Space>
|
||||
<div style={{ color: '#64748b', fontSize: 12, lineHeight: 1.7 }}>
|
||||
<div>大小:{formatSize(asset.fileSize)}</div>
|
||||
<div>轮询:{asset.pollCount || 0} 次</div>
|
||||
<div>创建:{formatDateTime(asset.createdAt)}</div>
|
||||
</div>
|
||||
{asset.errorMessage && <div style={{ color: '#ef4444', fontSize: 12 }}>{asset.errorMessage}</div>}
|
||||
<Space size={6} wrap>
|
||||
<Button size="small" icon={<CloudSyncOutlined />} onClick={() => handleSyncAsset(asset.id)}>同步</Button>
|
||||
<Popconfirm title="确认删除这个虚拟人像素材吗?" onConfirm={() => handleDeleteAsset(asset.id)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '94vh' }}>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/materials')}>返回素材云</Button>
|
||||
<Title level={4} style={{ margin: 0 }}>私域虚拟人像素材库</Title>
|
||||
</Space>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} md={8}>
|
||||
<Card style={{ borderRadius: 16, background: 'linear-gradient(135deg,#f5f3ff,#fff)' }}>
|
||||
<Text type="secondary">素材总额度</Text>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}</div>
|
||||
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>真人/虚拟共用,图片/视频共用;音频暂不开放。</Paragraph>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Card style={{ borderRadius: 16 }}>
|
||||
<Text type="secondary">项目组</Text>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{projects.length}</div>
|
||||
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>虚拟人像项目会同步创建火山 AIGC Asset Group。</Paragraph>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Card style={{ borderRadius: 16 }}>
|
||||
<Text type="secondary">当前项目素材</Text>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{selectedProject?.assetCount || 0}</div>
|
||||
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>仅 Active 状态素材可在 AI 创作中引用。</Paragraph>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={7}>
|
||||
<Card
|
||||
title="虚拟人像项目组"
|
||||
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>创建项目组</Button>}
|
||||
style={{ borderRadius: 16, minHeight: 520 }}
|
||||
>
|
||||
<Spin spinning={projectLoading}>
|
||||
{projects.length === 0 ? (
|
||||
<Empty description="暂无虚拟人像项目组" />
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={10}>
|
||||
{projects.map((project) => {
|
||||
const active = selectedProjectId === project.id;
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
onClick={() => setSelectedProjectId(project.id)}
|
||||
style={{
|
||||
padding: 14,
|
||||
borderRadius: 14,
|
||||
cursor: 'pointer',
|
||||
border: active ? '1px solid #8b5cf6' : '1px solid #e2e8f0',
|
||||
background: active ? '#f5f3ff' : '#fff',
|
||||
}}
|
||||
>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }} align="start">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text strong ellipsis style={{ display: 'block' }}>{project.name}</Text>
|
||||
{project.description && <Text type="secondary" ellipsis style={{ display: 'block', fontSize: 12 }}>{project.description}</Text>}
|
||||
</div>
|
||||
<StatusTag status={project.status} />
|
||||
</Space>
|
||||
<Space wrap size={4} style={{ marginTop: 10 }}>
|
||||
<Tag>总 {project.assetCount || 0}</Tag>
|
||||
<Tag color="green">图 {project.imageAssetCount || 0}</Tag>
|
||||
<Tag color="blue">视频 {project.videoAssetCount || 0}</Tag>
|
||||
<Tag color="success">Active {project.activeAssetCount || 0}</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={17}>
|
||||
<Card
|
||||
title={selectedProject ? selectedProject.name : '素材资产'}
|
||||
extra={(
|
||||
<Space wrap>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(assetPage, assetPageSize); }} loading={assetLoading}>刷新</Button>
|
||||
<Button type="primary" icon={<UploadOutlined />} disabled={!selectedProjectId} onClick={() => setUploadOpen(true)}>上传图片/视频</Button>
|
||||
{selectedProjectId && (
|
||||
<Popconfirm title="确认删除当前虚拟人像项目组吗?" onConfirm={handleDeleteProject}>
|
||||
<Button danger icon={<DeleteOutlined />}>删除项目组</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
style={{ borderRadius: 16, minHeight: 520 }}
|
||||
>
|
||||
<Space style={{ width: '100%', marginBottom: 16 }} wrap>
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="搜索素材名称"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onSearch={() => loadAssets(1, assetPageSize)}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="素材状态"
|
||||
value={assetStatus}
|
||||
onChange={(value) => setAssetStatus(value)}
|
||||
style={{ width: 150 }}
|
||||
options={[
|
||||
{ value: 'Processing', label: '处理中' },
|
||||
{ value: 'Active', label: '可用' },
|
||||
{ value: 'Failed', label: '失败' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="素材类型"
|
||||
value={assetType}
|
||||
onChange={(value) => setAssetType(value)}
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ value: 'Image', label: '图片' },
|
||||
{ value: 'Video', label: '视频' },
|
||||
]}
|
||||
/>
|
||||
<Button onClick={() => loadAssets(1, assetPageSize)}>筛选</Button>
|
||||
</Space>
|
||||
|
||||
<Spin spinning={assetLoading}>
|
||||
{!selectedProjectId ? (
|
||||
<Empty description="请先创建或选择项目组" style={{ marginTop: 80 }} />
|
||||
) : assets.length === 0 ? (
|
||||
<Empty description="暂无素材,上传图片/视频后会异步入库" style={{ marginTop: 80 }} />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 14 }}>
|
||||
{assets.map(renderAssetCard)}
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', marginTop: 16 }}>
|
||||
<Pagination
|
||||
current={assetPage}
|
||||
pageSize={assetPageSize}
|
||||
total={assetTotal}
|
||||
showSizeChanger
|
||||
showTotal={(value) => `共 ${value} 个素材`}
|
||||
onChange={(page, size) => loadAssets(page, size)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Modal
|
||||
title="创建虚拟人像项目组"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={handleCreateProject}
|
||||
confirmLoading={creatingProject}
|
||||
okText="创建并同步火山 Asset Group"
|
||||
>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="name" label="项目组名称" rules={[{ required: true, message: '请输入项目组名称' }]}>
|
||||
<Input placeholder="例如:虚拟主播A / 品牌代言人B" maxLength={128} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea placeholder="可填写人物设定、服装风格、素材要求等" maxLength={2000} rows={4} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="上传虚拟人像素材"
|
||||
open={uploadOpen}
|
||||
onCancel={() => setUploadOpen(false)}
|
||||
onOk={handleUpload}
|
||||
confirmLoading={uploading}
|
||||
okText="提交入库"
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={14}>
|
||||
<Input value={assetName} onChange={(e) => setAssetName(e.target.value)} placeholder="素材名称,默认使用文件名" maxLength={256} />
|
||||
<Upload
|
||||
accept="image/*,video/*"
|
||||
maxCount={1}
|
||||
fileList={fileList}
|
||||
beforeUpload={() => false}
|
||||
onChange={({ fileList: next }) => setFileList(next)}
|
||||
listType="picture"
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>选择图片或视频</Button>
|
||||
</Upload>
|
||||
<div style={{ padding: 12, background: '#f8fafc', borderRadius: 12, color: '#64748b', fontSize: 13 }}>
|
||||
当前开放图片和视频,音频暂不接入。提交后会调用火山 CreateAsset 异步处理,状态变为 Active 后才可用于 AI 创作。
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<Modal title="素材预览" open={previewOpen} onCancel={() => setPreviewOpen(false)} footer={null} width={760} destroyOnClose>
|
||||
<div style={{ minHeight: 420, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0f172a', borderRadius: 12, overflow: 'hidden' }}>
|
||||
{previewType === 'Video' ? (
|
||||
<video src={previewUrl} controls autoPlay style={{ maxWidth: '100%', maxHeight: 520 }} />
|
||||
) : (
|
||||
<img src={previewUrl} alt="素材预览" style={{ maxWidth: '100%', maxHeight: 520, objectFit: 'contain' }} />
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivatePortraitVirtualMaterialPage;
|
||||
@@ -638,7 +638,7 @@ function RemoveInfo() {
|
||||
<video
|
||||
controls
|
||||
src={videoUrl}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
style={{ width: '100%', height: '100%'}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -257,14 +257,23 @@ export interface AdminNotification {
|
||||
|
||||
export interface PrivatePortraitConfig {
|
||||
enabled: boolean;
|
||||
imageLimit: number;
|
||||
usedImageCount: number;
|
||||
remainingImageCount: number;
|
||||
assetLimit: number;
|
||||
usedAssetCount: number;
|
||||
remainingAssetCount: number;
|
||||
supportedAssetTypes?: string[];
|
||||
unsupportedAssetTypes?: string[];
|
||||
imageLimit?: number;
|
||||
usedImageCount?: number;
|
||||
remainingImageCount?: number;
|
||||
}
|
||||
|
||||
export type PrivatePortraitLibraryType = 'real_person' | 'aigc_virtual';
|
||||
export type PrivatePortraitAssetType = 'Image' | 'Video' | 'Audio';
|
||||
|
||||
export interface PrivatePortraitProject {
|
||||
id: string;
|
||||
userId?: string | null;
|
||||
libraryType?: PrivatePortraitLibraryType | string;
|
||||
name: string;
|
||||
nameSlug?: string | null;
|
||||
remoteProjectName?: string | null;
|
||||
@@ -272,7 +281,11 @@ export interface PrivatePortraitProject {
|
||||
status: string;
|
||||
assetGroupCount: number;
|
||||
assetCount: number;
|
||||
imageAssetCount?: number;
|
||||
videoAssetCount?: number;
|
||||
activeAssetCount: number;
|
||||
activeImageAssetCount?: number;
|
||||
activeVideoAssetCount?: number;
|
||||
lastUsedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
@@ -301,7 +314,6 @@ export interface PrivatePortraitValidateSession {
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
|
||||
export interface PrivatePortraitProjectCreateWithValidateOut {
|
||||
project: PrivatePortraitProject;
|
||||
validateSession: PrivatePortraitValidateSession;
|
||||
@@ -314,17 +326,30 @@ export interface PrivatePortraitAsset {
|
||||
projectId: string;
|
||||
projectName?: string | null;
|
||||
groupId: string;
|
||||
libraryType?: PrivatePortraitLibraryType | string;
|
||||
remoteGroupId: string;
|
||||
remoteAssetId?: string | null;
|
||||
remoteProjectName?: string | null;
|
||||
assetType: string;
|
||||
assetType: PrivatePortraitAssetType | string;
|
||||
name?: string | null;
|
||||
sourceUrl: string;
|
||||
previewUrl?: string | null;
|
||||
displayUrl?: string | null;
|
||||
providerUrl?: string | null;
|
||||
remoteUrl?: string | null;
|
||||
remoteUrlExpiredAt?: string | null;
|
||||
videoDuration?: number | null;
|
||||
videoCoverUrl?: string | null;
|
||||
fileSize?: number | null;
|
||||
mimeType?: string | null;
|
||||
status: string;
|
||||
moderation?: unknown;
|
||||
lastPollAt?: string | null;
|
||||
nextPollAt?: string | null;
|
||||
pollCount: number;
|
||||
remoteDeleteStatus: string;
|
||||
remoteDeletedAt?: string | null;
|
||||
remoteDeleteError?: string | null;
|
||||
errorMessage?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
@@ -341,9 +366,14 @@ export interface PrivatePortraitSelectableAsset {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
libraryType?: PrivatePortraitLibraryType | string;
|
||||
name?: string | null;
|
||||
assetType: string;
|
||||
assetType: PrivatePortraitAssetType | string;
|
||||
previewUrl?: string | null;
|
||||
displayUrl?: string | null;
|
||||
providerUrl?: string | null;
|
||||
videoDuration?: number | null;
|
||||
videoCoverUrl?: string | null;
|
||||
status: string;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
@@ -354,3 +384,4 @@ export interface PrivatePortraitSelectableAssetListOut {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user