1
This commit is contained in:
Vendored
+110
-110
File diff suppressed because one or more lines are too long
Vendored
+36
-36
@@ -1,37 +1,37 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<title>后台管理</title>
|
<title>后台管理</title>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var cached = localStorage.getItem('siteInfo');
|
var cached = localStorage.getItem('siteInfo');
|
||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
try {
|
||||||
var info = JSON.parse(cached);
|
var info = JSON.parse(cached);
|
||||||
if (info.siteName) {
|
if (info.siteName) {
|
||||||
document.title = info.siteName + ' - 管理后台';
|
document.title = info.siteName + ' - 管理后台';
|
||||||
}
|
}
|
||||||
if (info.siteLogo) {
|
if (info.siteLogo) {
|
||||||
var link = document.querySelector('link[rel="icon"]');
|
var link = document.querySelector('link[rel="icon"]');
|
||||||
if (link) {
|
if (link) {
|
||||||
link.href = info.siteLogo;
|
link.href = info.siteLogo;
|
||||||
link.type = 'image/png';
|
link.type = 'image/png';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</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">
|
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>␍
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -448,7 +448,7 @@ export async function deleteMenuConfig(id: string): Promise<void> {
|
|||||||
|
|
||||||
// ── User Creation ───────────────────────────────────────
|
// ── 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);
|
return api.post('/admin/users', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1017,22 +1017,25 @@ export async function getPreTestTemplateList(params: PreTestTemplateListParams):
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Private Portrait Admin ────────────────────────────────
|
// ── 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();
|
const query = new URLSearchParams();
|
||||||
query.set('page', String(params.page || 1));
|
query.set('page', String(params.page || 1));
|
||||||
query.set('page_size', String(params.pageSize || 20));
|
query.set('page_size', String(params.pageSize || 20));
|
||||||
if (params.userId) query.set('user_id', params.userId);
|
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.keyword) query.set('keyword', params.keyword);
|
||||||
if (params.status) query.set('status', params.status);
|
if (params.status) query.set('status', params.status);
|
||||||
return api.get<PrivatePortraitProjectListOut>(`/admin/private-portrait/projects?${query.toString()}`);
|
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();
|
const query = new URLSearchParams();
|
||||||
query.set('page', String(params.page || 1));
|
query.set('page', String(params.page || 1));
|
||||||
query.set('page_size', String(params.pageSize || 20));
|
query.set('page_size', String(params.pageSize || 20));
|
||||||
if (params.userId) query.set('user_id', params.userId);
|
if (params.userId) query.set('user_id', params.userId);
|
||||||
if (params.projectId) query.set('project_id', params.projectId);
|
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.keyword) query.set('keyword', params.keyword);
|
||||||
if (params.status) query.set('status', params.status);
|
if (params.status) query.set('status', params.status);
|
||||||
return api.get<PrivatePortraitAssetListOut>(`/admin/private-portrait/assets?${query.toString()}`);
|
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> {
|
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 React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Button, Card, Input, Space, Table, Tag, Typography, message } from 'antd';
|
import { Button, Card, Image, Input, Select, Space, Statistic, Table, Tag, Tooltip, Typography, message } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
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 { adminGetPrivatePortraitAssets, adminGetPrivatePortraitProjects } from '../api';
|
||||||
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../types';
|
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../types';
|
||||||
|
import { formatDate } from '../utils/formatDate';
|
||||||
|
import { apiUrl } from '../utils/resourceUrl';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
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) => {
|
const statusColor = (status?: string) => {
|
||||||
if (status === 'Active' || status === 'active') return 'green';
|
if (status === 'Active' || status === 'active') return 'green';
|
||||||
if (status === 'Processing') return 'blue';
|
if (status === 'Processing' || status === 'creating_remote_group' || status === 'validating' || status === 'creating') return 'blue';
|
||||||
if (status === 'Failed' || status === 'failed' || status === 'delete_failed') return 'red';
|
if (status === 'Failed' || status === 'failed' || status === 'delete_failed' || status === 'validate_failed' || status === 'create_group_failed') return 'red';
|
||||||
if (status?.includes('deleted')) return 'default';
|
if (status?.includes('deleted')) return 'default';
|
||||||
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 AdminPrivatePortraitProjects: React.FC = () => {
|
||||||
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
||||||
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
|
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
|
||||||
@@ -23,18 +113,36 @@ const AdminPrivatePortraitProjects: React.FC = () => {
|
|||||||
const [loadingProjects, setLoadingProjects] = useState(false);
|
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||||
const [loadingAssets, setLoadingAssets] = useState(false);
|
const [loadingAssets, setLoadingAssets] = useState(false);
|
||||||
const [keyword, setKeyword] = useState('');
|
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 [selectedProjectId, setSelectedProjectId] = useState<string | undefined>();
|
||||||
const [projectPage, setProjectPage] = useState(1);
|
const [projectPage, setProjectPage] = useState(1);
|
||||||
const [assetPage, setAssetPage] = 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 () => {
|
const loadProjects = async () => {
|
||||||
setLoadingProjects(true);
|
setLoadingProjects(true);
|
||||||
try {
|
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 || []);
|
setProjects(res.items || []);
|
||||||
setProjectTotal(res.total || 0);
|
setProjectTotal(res.total || 0);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
message.error(err?.message || '加载真人素材项目失败');
|
message.error(err?.message || '加载私域人像素材项目失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingProjects(false);
|
setLoadingProjects(false);
|
||||||
}
|
}
|
||||||
@@ -43,88 +151,111 @@ const AdminPrivatePortraitProjects: React.FC = () => {
|
|||||||
const loadAssets = async () => {
|
const loadAssets = async () => {
|
||||||
setLoadingAssets(true);
|
setLoadingAssets(true);
|
||||||
try {
|
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 || []);
|
setAssets(res.items || []);
|
||||||
setAssetTotal(res.total || 0);
|
setAssetTotal(res.total || 0);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
message.error(err?.message || '加载真人素材失败');
|
message.error(err?.message || '加载私域人像素材失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingAssets(false);
|
setLoadingAssets(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { loadProjects(); }, [projectPage]);
|
useEffect(() => { loadProjects(); }, [projectPage, filters.libraryType, filters.projectStatus]);
|
||||||
useEffect(() => { loadAssets(); }, [selectedProjectId, assetPage]);
|
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> = [
|
const projectColumns: ColumnsType<PrivatePortraitProject> = [
|
||||||
{ title: '项目名称', dataIndex: 'name', width: 180, render: (v, r) => <Button type="link" onClick={() => { setSelectedProjectId(r.id); setAssetPage(1); }}>{v}</Button> },
|
{ title: '项目名称', dataIndex: 'name', width: 200, fixed: 'left', 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: 'libraryType', width: 110, render: (v) => <Tag color={libraryColor(v)}>{libraryLabel(v)}</Tag> },
|
||||||
{ title: '状态', dataIndex: 'status', width: 100, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
|
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
|
||||||
{ title: 'ProjectName', dataIndex: 'remoteProjectName', width: 240, render: (v) => <Text code copyable>{v || '-'}</Text> },
|
{ title: '状态', dataIndex: 'status', width: 130, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
|
||||||
{ title: '素材数', dataIndex: 'assetCount', width: 100 },
|
{ 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: 100 },
|
{ title: 'Active', dataIndex: 'activeAssetCount', width: 90 },
|
||||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v) => v || '-' },
|
{ 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> = [
|
const assetColumns: ColumnsType<PrivatePortraitAsset> = [
|
||||||
{ title: '素材', dataIndex: 'name', width: 180, render: (v, r) => v || r.remoteAssetId || '-' },
|
{ title: '预览', width: 100, fixed: 'left', render: (_, r) => <PreviewCell asset={r} /> },
|
||||||
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code>{v || '-'}</Text> },
|
{ title: '素材名称', dataIndex: 'name', width: 180, render: (v, r) => <div><Text strong>{v || '未命名素材'}</Text><br /><Text type="secondary">{assetTypeLabel(r.assetType)}</Text></div> },
|
||||||
{ title: '本地项目', dataIndex: 'projectName', width: 160 },
|
{ title: '库类型', dataIndex: 'libraryType', width: 110, render: (v) => <Tag color={libraryColor(v)}>{libraryLabel(v)}</Tag> },
|
||||||
{ title: 'AssetId', dataIndex: 'remoteAssetId', width: 240, render: (v) => <Text code copyable>{v}</Text> },
|
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
|
||||||
{ title: 'GroupId', dataIndex: 'remoteGroupId', width: 240, render: (v) => <Text code copyable>{v}</Text> },
|
{ title: '本地项目', dataIndex: 'projectName', width: 160, render: (v) => v || '-' },
|
||||||
{ title: 'ProjectName', dataIndex: 'remoteProjectName', width: 240, render: (v) => <Text code copyable>{v || '-'}</Text> },
|
{ title: '状态', dataIndex: 'status', width: 120, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
|
||||||
{ title: '类型', dataIndex: 'assetType', width: 90 },
|
{ 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: '状态', dataIndex: 'status', width: 110, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
|
{ 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: '错误', dataIndex: 'errorMessage', width: 240, ellipsis: true, render: (v) => v || '-' },
|
{ title: '轮询', width: 160, render: (_, r) => <div><Text>次数:{r.pollCount || 0}</Text><br /><Text type="secondary">下次:{formatDate((r as any).nextPollAt)}</Text></div> },
|
||||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v) => v || '-' },
|
{ 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 (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{ padding: 24, background: '#f7f7fb', minHeight: '100%' }}>
|
||||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}>
|
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} align="start">
|
||||||
<div>
|
<div>
|
||||||
<Title level={3} style={{ margin: 0 }}>真人素材库</Title>
|
<Title level={3} style={{ margin: 0 }}>私域人像素材库</Title>
|
||||||
<Text type="secondary">只读排查页:查看用户项目组、Asset Group 与 Asset 状态。</Text>
|
<Text type="secondary">统一查看真人认证素材与虚拟人像素材,支持图片/视频资产状态排查。</Text>
|
||||||
</div>
|
</div>
|
||||||
<Space>
|
<Space wrap>
|
||||||
<Input
|
<Input allowClear prefix={<SearchOutlined />} placeholder="搜索项目/素材" value={keyword} onChange={(e) => setKeyword(e.target.value)} onPressEnter={searchAll} style={{ width: 240 }} />
|
||||||
allowClear
|
<Select value={libraryType} onChange={(v) => { setLibraryType(v); setProjectPage(1); setAssetPage(1); }} options={LIBRARY_OPTIONS} style={{ width: 150 }} />
|
||||||
prefix={<SearchOutlined />}
|
<Select value={assetType} onChange={(v) => { setAssetType(v); setAssetPage(1); }} options={ASSET_TYPE_OPTIONS} style={{ width: 130 }} />
|
||||||
placeholder="搜索项目/素材"
|
<Button icon={<SearchOutlined />} type="primary" onClick={searchAll}>查询</Button>
|
||||||
value={keyword}
|
<Button onClick={resetFilters}>重置</Button>
|
||||||
onChange={(e) => setKeyword(e.target.value)}
|
|
||||||
onPressEnter={() => { setProjectPage(1); setAssetPage(1); loadProjects(); loadAssets(); }}
|
|
||||||
style={{ width: 260 }}
|
|
||||||
/>
|
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(); }}>刷新</Button>
|
<Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(); }}>刷新</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Card title="真人素材项目组" style={{ marginBottom: 16 }}>
|
<Space size={16} wrap style={{ marginBottom: 16 }}>
|
||||||
<Table
|
<Card style={{ width: 180 }}><Statistic title="项目数" value={projectTotal} /></Card>
|
||||||
rowKey="id"
|
<Card style={{ width: 180 }}><Statistic title="当前页素材" value={projectSummary.asset} /></Card>
|
||||||
columns={projectColumns}
|
<Card style={{ width: 180 }}><Statistic title="图片" value={projectSummary.image} /></Card>
|
||||||
dataSource={projects}
|
<Card style={{ width: 180 }}><Statistic title="视频" value={projectSummary.video} /></Card>
|
||||||
loading={loadingProjects}
|
<Card style={{ width: 180 }}><Statistic title="Active" value={projectSummary.active} /></Card>
|
||||||
pagination={{ current: projectPage, pageSize: 20, total: projectTotal, onChange: setProjectPage }}
|
</Space>
|
||||||
size="small"
|
|
||||||
scroll={{ x: 900 }}
|
<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>
|
||||||
|
|
||||||
<Card
|
<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>}>
|
||||||
title={selectedProjectId ? '项目素材明细' : '全部素材明细'}
|
<Table rowKey="id" columns={assetColumns} dataSource={assets} loading={loadingAssets} pagination={{ current: assetPage, pageSize: PAGE_SIZE, total: assetTotal, onChange: setAssetPage }} size="small" scroll={{ x: 1700 }} />
|
||||||
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>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ const AdminUsers: React.FC = () => {
|
|||||||
credits: values.credits || 0,
|
credits: values.credits || 0,
|
||||||
user_type: userType,
|
user_type: userType,
|
||||||
frontend_user_kind: values.frontend_user_kind || 'external',
|
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('用户创建成功');
|
message.success('用户创建成功');
|
||||||
setCreateModal(false);
|
setCreateModal(false);
|
||||||
@@ -276,13 +276,13 @@ const AdminUsers: React.FC = () => {
|
|||||||
const openPortraitModal = async (user: AdminUser) => {
|
const openPortraitModal = async (user: AdminUser) => {
|
||||||
setPortraitLoading(true);
|
setPortraitLoading(true);
|
||||||
setPortraitModal({ open: true, user, config: null });
|
setPortraitModal({ open: true, user, config: null });
|
||||||
portraitForm.setFieldsValue({ privatePortraitImageLimit: user.privatePortraitImageLimit ?? 5 });
|
portraitForm.setFieldsValue({ privatePortraitAssetLimit: user.privatePortraitAssetLimit ?? 5 });
|
||||||
try {
|
try {
|
||||||
const config = await adminGetPrivatePortraitConfig(user.id);
|
const config = await adminGetPrivatePortraitConfig(user.id);
|
||||||
portraitForm.setFieldsValue({ privatePortraitImageLimit: config.imageLimit });
|
portraitForm.setFieldsValue({ privatePortraitAssetLimit: config.assetLimit });
|
||||||
setPortraitModal({ open: true, user, config });
|
setPortraitModal({ open: true, user, config });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || '加载真人素材库配置失败');
|
message.error(e?.message || '加载私域人像素材库配置失败');
|
||||||
setPortraitModal({ open: false, user: null, config: null });
|
setPortraitModal({ open: false, user: null, config: null });
|
||||||
} finally {
|
} finally {
|
||||||
setPortraitLoading(false);
|
setPortraitLoading(false);
|
||||||
@@ -294,16 +294,16 @@ const AdminUsers: React.FC = () => {
|
|||||||
if (!user) return;
|
if (!user) return;
|
||||||
try {
|
try {
|
||||||
const values = await portraitForm.validateFields();
|
const values = await portraitForm.validateFields();
|
||||||
const limit = Number(values.privatePortraitImageLimit ?? 0);
|
const limit = Number(values.privatePortraitAssetLimit ?? 0);
|
||||||
setPortraitSaving(true);
|
setPortraitSaving(true);
|
||||||
const config = await adminUpdatePrivatePortraitConfig(user.id, limit);
|
const config = await adminUpdatePrivatePortraitConfig(user.id, limit);
|
||||||
message.success(limit > 0 ? `真人素材库已开启,限制 ${limit} 张` : '真人素材库已关闭');
|
message.success(limit > 0 ? `私域人像素材库已开启,限制 ${limit} 个` : '私域人像素材库已关闭');
|
||||||
setPortraitModal({ open: false, user: null, config });
|
setPortraitModal({ open: false, user: null, config });
|
||||||
portraitForm.resetFields();
|
portraitForm.resetFields();
|
||||||
load();
|
load();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.errorFields) return;
|
if (e?.errorFields) return;
|
||||||
message.error(e?.message || '保存真人素材库配置失败');
|
message.error(e?.message || '保存私域人像素材库配置失败');
|
||||||
} finally {
|
} finally {
|
||||||
setPortraitSaving(false);
|
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>,
|
render: (v: string | null | undefined) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary">未分配</Typography.Text>,
|
||||||
}] : []),
|
}] : []),
|
||||||
...(!isAdminTab ? [{
|
...(!isAdminTab ? [{
|
||||||
title: '真人素材库', dataIndex: 'privatePortraitImageLimit', width: 150,
|
title: '私域人像素材库', dataIndex: 'privatePortraitAssetLimit', width: 150,
|
||||||
render: (v: number) => {
|
render: (v: number) => {
|
||||||
const limit = Number(v || 0);
|
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 ? [{
|
...(!isAdminTab ? [{
|
||||||
@@ -493,7 +493,7 @@ const AdminUsers: React.FC = () => {
|
|||||||
{!isAdminTab && (
|
{!isAdminTab && (
|
||||||
<Button type="link" size="small" icon={<PictureOutlined />}
|
<Button type="link" size="small" icon={<PictureOutlined />}
|
||||||
onClick={() => openPortraitModal(r)}>
|
onClick={() => openPortraitModal(r)}>
|
||||||
真人素材
|
私域人像素材
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!isAdminTab && (
|
{!isAdminTab && (
|
||||||
@@ -784,7 +784,7 @@ const AdminUsers: React.FC = () => {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={<Space><PictureOutlined />真人素材库设置 - {portraitModal.user?.username}</Space>}
|
title={<Space><PictureOutlined />私域人像素材库设置 - {portraitModal.user?.username}</Space>}
|
||||||
open={portraitModal.open}
|
open={portraitModal.open}
|
||||||
confirmLoading={portraitSaving}
|
confirmLoading={portraitSaving}
|
||||||
onOk={handleSavePortraitConfig}
|
onOk={handleSavePortraitConfig}
|
||||||
@@ -797,17 +797,17 @@ const AdminUsers: React.FC = () => {
|
|||||||
当前状态:{portraitModal.config?.enabled ? <Tag color="purple">已开启</Tag> : <Tag>未开启</Tag>}
|
当前状态:{portraitModal.config?.enabled ? <Tag color="purple">已开启</Tag> : <Tag>未开启</Tag>}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
已用:{portraitModal.config?.usedImageCount ?? '-'} 张;
|
已用:{portraitModal.config?.usedAssetCount ?? '-'} 个;
|
||||||
剩余:{portraitModal.config?.enabled ? portraitModal.config.remainingImageCount : 0} 张
|
剩余:{portraitModal.config?.enabled ? portraitModal.config.remainingAssetCount : 0} 个
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
<Form form={portraitForm} layout="vertical">
|
<Form form={portraitForm} layout="vertical">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="privatePortraitImageLimit"
|
name="privatePortraitAssetLimit"
|
||||||
label="真人素材库图片上限"
|
label="私域人像素材总量上限"
|
||||||
extra="0 表示关闭真人素材库;大于 0 表示开启,并限制该用户所有真人素材图片总量。"
|
extra="0 表示关闭私域人像素材库;大于 0 表示开启,并限制该用户所有私域人像素材总量。"
|
||||||
rules={[{ required: true, message: '请输入真人素材库图片上限' }]}
|
rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
|
||||||
>
|
>
|
||||||
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
|
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -857,11 +857,11 @@ const AdminUsers: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
{createType === 'frontend' && (
|
{createType === 'frontend' && (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="private_portrait_image_limit"
|
name="private_portrait_asset_limit"
|
||||||
label="真人素材库图片上限"
|
label="私域人像素材总量上限"
|
||||||
initialValue={5}
|
initialValue={5}
|
||||||
extra="0 表示关闭真人素材库;大于 0 表示开启并限制该用户所有真人素材图片总量。"
|
extra="0 表示关闭私域人像素材库;大于 0 表示开启并限制该用户所有私域人像素材总量。"
|
||||||
rules={[{ required: true, message: '请输入真人素材库图片上限' }]}
|
rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
|
||||||
>
|
>
|
||||||
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
|
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface User {
|
|||||||
userType: string;
|
userType: string;
|
||||||
allowedMenus?: string[] | null;
|
allowedMenus?: string[] | null;
|
||||||
resourceCapacity?: ResourceCapacityUsage | null;
|
resourceCapacity?: ResourceCapacityUsage | null;
|
||||||
privatePortraitImageLimit: number;
|
privatePortraitAssetLimit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreditRecord {
|
export interface CreditRecord {
|
||||||
@@ -194,7 +194,7 @@ export interface AdminUser {
|
|||||||
lastLoginAt?: string;
|
lastLoginAt?: string;
|
||||||
allowedMenus?: string[] | null;
|
allowedMenus?: string[] | null;
|
||||||
resourceCapacity?: ResourceCapacityUsage | null;
|
resourceCapacity?: ResourceCapacityUsage | null;
|
||||||
privatePortraitImageLimit: number;
|
privatePortraitAssetLimit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminStats {
|
export interface AdminStats {
|
||||||
@@ -1125,14 +1125,20 @@ export interface HomeMaterialTextWatermarkPreviewResponse {
|
|||||||
|
|
||||||
export interface PrivatePortraitConfig {
|
export interface PrivatePortraitConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
imageLimit: number;
|
assetLimit: number;
|
||||||
usedImageCount: number;
|
usedAssetCount: number;
|
||||||
remainingImageCount: number;
|
remainingAssetCount: number;
|
||||||
|
supportedAssetTypes?: string[];
|
||||||
|
unsupportedAssetTypes?: string[];
|
||||||
|
imageLimit?: number;
|
||||||
|
usedImageCount?: number;
|
||||||
|
remainingImageCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrivatePortraitProject {
|
export interface PrivatePortraitProject {
|
||||||
id: string;
|
id: string;
|
||||||
userId?: string | null;
|
userId?: string | null;
|
||||||
|
libraryType: string;
|
||||||
name: string;
|
name: string;
|
||||||
nameSlug?: string | null;
|
nameSlug?: string | null;
|
||||||
remoteProjectName?: string | null;
|
remoteProjectName?: string | null;
|
||||||
@@ -1140,7 +1146,11 @@ export interface PrivatePortraitProject {
|
|||||||
status: string;
|
status: string;
|
||||||
assetGroupCount: number;
|
assetGroupCount: number;
|
||||||
assetCount: number;
|
assetCount: number;
|
||||||
|
imageAssetCount?: number;
|
||||||
|
videoAssetCount?: number;
|
||||||
activeAssetCount: number;
|
activeAssetCount: number;
|
||||||
|
activeImageAssetCount?: number;
|
||||||
|
activeVideoAssetCount?: number;
|
||||||
lastUsedAt?: string | null;
|
lastUsedAt?: string | null;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
@@ -1174,6 +1184,7 @@ export interface PrivatePortraitAsset {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
projectName?: string | null;
|
projectName?: string | null;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
|
libraryType: string;
|
||||||
remoteGroupId: string;
|
remoteGroupId: string;
|
||||||
remoteAssetId?: string | null;
|
remoteAssetId?: string | null;
|
||||||
remoteProjectName?: string | null;
|
remoteProjectName?: string | null;
|
||||||
@@ -1181,7 +1192,13 @@ export interface PrivatePortraitAsset {
|
|||||||
name?: string | null;
|
name?: string | null;
|
||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
previewUrl?: string | null;
|
previewUrl?: string | null;
|
||||||
|
displayUrl?: string | null;
|
||||||
|
providerUrl?: string | null;
|
||||||
remoteUrl?: string | null;
|
remoteUrl?: string | null;
|
||||||
|
videoDuration?: number | null;
|
||||||
|
videoCoverUrl?: string | null;
|
||||||
|
fileSize?: number | null;
|
||||||
|
mimeType?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
pollCount: number;
|
pollCount: number;
|
||||||
remoteDeleteStatus: string;
|
remoteDeleteStatus: string;
|
||||||
@@ -1201,9 +1218,14 @@ export interface PrivatePortraitSelectableAsset {
|
|||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
|
libraryType: string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
assetType: string;
|
assetType: string;
|
||||||
previewUrl?: string | null;
|
previewUrl?: string | null;
|
||||||
|
displayUrl?: string | null;
|
||||||
|
providerUrl?: string | null;
|
||||||
|
videoDuration?: number | null;
|
||||||
|
videoCoverUrl?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ VIDEO_COVER_WIDTH=600
|
|||||||
VIDEO_COVER_TIMEOUT_SECONDS=15
|
VIDEO_COVER_TIMEOUT_SECONDS=15
|
||||||
VIDEO_COVER_FORMAT=png
|
VIDEO_COVER_FORMAT=png
|
||||||
|
|
||||||
|
# VOLC
|
||||||
|
VOLC_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
|
||||||
|
VOLC_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
|
||||||
|
|
||||||
# VOLC_SMS
|
# VOLC_SMS
|
||||||
VOLC_SMS_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
|
VOLC_SMS_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
|
||||||
VOLC_SMS_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
|
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.models.user import User
|
||||||
from app.schemas.private_portrait import (
|
from app.schemas.private_portrait import (
|
||||||
PrivatePortraitAdminConfigUpdate,
|
PrivatePortraitAdminConfigUpdate,
|
||||||
|
PrivatePortraitAdminStatsOut,
|
||||||
PrivatePortraitAssetListOut,
|
PrivatePortraitAssetListOut,
|
||||||
PrivatePortraitConfigOut,
|
PrivatePortraitConfigOut,
|
||||||
PrivatePortraitProjectListOut,
|
PrivatePortraitProjectListOut,
|
||||||
)
|
)
|
||||||
from app.services.operation_log import log_operation
|
from app.services.operation_log import log_operation
|
||||||
from app.services.private_portrait.asset_service import (
|
from app.services.private_portrait.admin.asset_query_service import admin_list_assets
|
||||||
asset_to_out,
|
from app.services.private_portrait.admin.project_query_service import admin_list_projects
|
||||||
get_user_private_portrait_config,
|
from app.services.private_portrait.admin.stats_query_service import admin_get_private_portrait_stats
|
||||||
list_assets,
|
from app.services.private_portrait.quota_service import get_user_private_portrait_config, set_user_private_portrait_limit
|
||||||
set_user_private_portrait_limit,
|
|
||||||
)
|
|
||||||
from app.services.private_portrait.project_service import list_projects, project_to_out
|
|
||||||
|
|
||||||
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:
|
def _json_detail(data: dict[str, Any]) -> str:
|
||||||
return json.dumps(data, ensure_ascii=False, default=str)
|
return json.dumps(data, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users/{user_id}/config", response_model=PrivatePortraitConfigOut)
|
@router.get("/users/{user_id}/config", response_model=PrivatePortraitConfigOut, summary="管理后台:查看用户私域人像素材额度")
|
||||||
async def admin_get_private_portrait_config(
|
async def admin_get_private_portrait_config(user_id: str, admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||||||
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)
|
config = await get_user_private_portrait_config(db, user_id=user_id)
|
||||||
await log_operation(
|
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")}))
|
||||||
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
|
return config
|
||||||
|
|
||||||
|
|
||||||
@router.put("/users/{user_id}/config", response_model=PrivatePortraitConfigOut)
|
@router.put("/users/{user_id}/config", response_model=PrivatePortraitConfigOut, summary="管理后台:设置用户私域人像素材总量")
|
||||||
async def admin_update_private_portrait_config(
|
async def admin_update_private_portrait_config(user_id: str, payload: PrivatePortraitAdminConfigUpdate, admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||||||
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)
|
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)
|
after = await get_user_private_portrait_config(db, user_id=user_id)
|
||||||
await log_operation(
|
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")}))
|
||||||
db,
|
await db.commit()
|
||||||
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"),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return after
|
return after
|
||||||
|
|
||||||
|
|
||||||
@router.get("/projects", response_model=PrivatePortraitProjectListOut)
|
@router.get("/stats", response_model=PrivatePortraitAdminStatsOut, summary="管理后台:私域人像素材统计")
|
||||||
async def admin_list_private_portrait_projects(
|
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)):
|
||||||
page: int = Query(1, ge=1),
|
stats = await admin_get_private_portrait_stats(db, user_id=user_id, library_type=library_type)
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
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")}))
|
||||||
user_id: str | None = Query(None),
|
return stats
|
||||||
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("/assets", response_model=PrivatePortraitAssetListOut)
|
@router.get("/projects", response_model=PrivatePortraitProjectListOut, summary="管理后台:查询私域人像项目列表")
|
||||||
async def admin_list_private_portrait_assets(
|
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)):
|
||||||
page: int = Query(1, ge=1),
|
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)
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
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)}))
|
||||||
user_id: str | None = Query(None),
|
await db.commit()
|
||||||
project_id: str | None = Query(None),
|
return PrivatePortraitProjectListOut(items=items, total=total, page=page, page_size=page_size)
|
||||||
keyword: str | None = Query(None),
|
|
||||||
status: str | None = Query(None),
|
|
||||||
admin: User = Depends(get_admin_user),
|
@router.get("/assets", response_model=PrivatePortraitAssetListOut, summary="管理后台:查询私域人像素材列表")
|
||||||
db: AsyncSession = Depends(get_db),
|
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)
|
||||||
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, "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 log_operation(
|
await db.commit()
|
||||||
db,
|
return PrivatePortraitAssetListOut(items=items, total=total, page=page, page_size=page_size)
|
||||||
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)
|
|
||||||
|
|||||||
@@ -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.admin import router as admin_module_router
|
||||||
from app.api.v1.material_admin import router as material_admin_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 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 = APIRouter()
|
||||||
api_router.include_router(auth_router)
|
api_router.include_router(auth_router)
|
||||||
@@ -67,4 +68,5 @@ api_router.include_router(team_router)
|
|||||||
api_router.include_router(home_materials_router)
|
api_router.include_router(home_materials_router)
|
||||||
api_router.include_router(admin_module_router)
|
api_router.include_router(admin_module_router)
|
||||||
api_router.include_router(material_admin_router)
|
api_router.include_router(material_admin_router)
|
||||||
api_router.include_router(private_portrait_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,
|
user_type=req.user_type,
|
||||||
frontend_user_kind=req.frontend_user_kind if req.user_type == "frontend" else FrontendUserKind.EXTERNAL.value,
|
frontend_user_kind=req.frontend_user_kind if req.user_type == "frontend" else FrontendUserKind.EXTERNAL.value,
|
||||||
allowed_menus=req.allowed_menus,
|
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)
|
user.credits = round(user.credits, 2)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
@@ -192,7 +192,7 @@ async def create_user(
|
|||||||
"username": username,
|
"username": username,
|
||||||
"user_type": req.user_type,
|
"user_type": req.user_type,
|
||||||
"frontend_user_kind": user.frontend_user_kind,
|
"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,
|
"credits": user.credits,
|
||||||
"phone": user.phone,
|
"phone": user.phone,
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.dependencies import get_current_user, get_db
|
from app.dependencies import get_current_user, get_db
|
||||||
from app.enums.private_portrait import (
|
from app.enums.private_portrait import (
|
||||||
|
PrivatePortraitAssetType,
|
||||||
PrivatePortraitEventSource,
|
PrivatePortraitEventSource,
|
||||||
PrivatePortraitEventStatus,
|
PrivatePortraitEventStatus,
|
||||||
PrivatePortraitEventType,
|
PrivatePortraitEventType,
|
||||||
|
PrivatePortraitLibraryType,
|
||||||
PrivatePortraitProjectStatus,
|
PrivatePortraitProjectStatus,
|
||||||
PrivatePortraitRemoteDeleteStatus,
|
PrivatePortraitRemoteDeleteStatus,
|
||||||
)
|
)
|
||||||
@@ -20,6 +22,7 @@ from app.models.user import User
|
|||||||
from app.schemas.private_portrait import (
|
from app.schemas.private_portrait import (
|
||||||
PrivatePortraitAssetCreate,
|
PrivatePortraitAssetCreate,
|
||||||
PrivatePortraitAssetListOut,
|
PrivatePortraitAssetListOut,
|
||||||
|
PrivatePortraitAssetOut,
|
||||||
PrivatePortraitDeleteOut,
|
PrivatePortraitDeleteOut,
|
||||||
PrivatePortraitConfigOut,
|
PrivatePortraitConfigOut,
|
||||||
PrivatePortraitProjectCreate,
|
PrivatePortraitProjectCreate,
|
||||||
@@ -30,13 +33,13 @@ from app.schemas.private_portrait import (
|
|||||||
PrivatePortraitSelectableAssetListOut,
|
PrivatePortraitSelectableAssetListOut,
|
||||||
PrivatePortraitValidateSessionCreate,
|
PrivatePortraitValidateSessionCreate,
|
||||||
PrivatePortraitValidateSessionOut,
|
PrivatePortraitValidateSessionOut,
|
||||||
|
PrivatePortraitEnumMetaOut,
|
||||||
|
build_private_portrait_enum_meta,
|
||||||
)
|
)
|
||||||
from app.services.operation_log_service import log_operation_error, log_operation_event
|
from app.services.operation_log_service import log_operation_error, log_operation_event
|
||||||
from app.services.private_portrait.asset_service import (
|
from app.services.private_portrait.asset_service import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
asset_to_out,
|
asset_to_out,
|
||||||
create_asset,
|
|
||||||
create_validate_session,
|
|
||||||
get_user_private_portrait_config,
|
get_user_private_portrait_config,
|
||||||
get_validate_session,
|
get_validate_session,
|
||||||
handle_validate_callback,
|
handle_validate_callback,
|
||||||
@@ -47,100 +50,102 @@ from app.services.private_portrait.asset_service import (
|
|||||||
validate_session_to_out,
|
validate_session_to_out,
|
||||||
)
|
)
|
||||||
from app.services.private_portrait.project_service import (
|
from app.services.private_portrait.project_service import (
|
||||||
create_project,
|
|
||||||
get_user_project,
|
get_user_project,
|
||||||
list_projects,
|
list_projects,
|
||||||
project_to_out,
|
project_to_out,
|
||||||
refresh_project_counters,
|
refresh_project_counters,
|
||||||
soft_delete_project,
|
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:
|
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(
|
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})
|
||||||
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:
|
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(
|
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})
|
||||||
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)):
|
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)
|
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)):
|
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)
|
project = await create_real_person_project(db, user_id=current_user.id, payload=payload)
|
||||||
session = await create_validate_session(
|
session = await create_real_person_validate_session(db, user_id=current_user.id, project_id=project.id, callback_redirect_url=payload.callback_redirect_url)
|
||||||
db,
|
out = PrivatePortraitProjectCreateWithValidateOut(project=project_to_out(project), validate_session=validate_session_to_out(session), poll_interval_ms=2000)
|
||||||
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()
|
await db.commit()
|
||||||
return out
|
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(
|
async def list_private_portrait_projects(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1, description="页码,从 1 开始。"),
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100。"),
|
||||||
keyword: str | None = Query(None),
|
keyword: str | None = Query(None, description="项目名称模糊搜索。"),
|
||||||
status: str | None = Query(None),
|
status: str | None = Query(None, description="项目状态,不传默认 active。"),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
query_status = status or PrivatePortraitProjectStatus.ACTIVE.value
|
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 refresh_project_counters(db, [item.id for item in items])
|
||||||
await db.commit()
|
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)
|
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)):
|
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)):
|
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)
|
out = project_to_out(project)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return out
|
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)):
|
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
|
project_id_snapshot = project.id
|
||||||
await db.commit()
|
await db.commit()
|
||||||
try:
|
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)
|
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)):
|
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)
|
out = validate_session_to_out(session)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return out
|
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)):
|
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))
|
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)):
|
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 = dict(request.query_params)
|
||||||
params.pop("session_id", None)
|
params.pop("session_id", None)
|
||||||
params.pop("redirect_url", None)
|
params.pop("redirect_url", None)
|
||||||
session = await handle_validate_callback(db, session_id=session_id, query_params=params)
|
session = await handle_validate_callback(db, session_id=session_id, query_params=params)
|
||||||
redirect_params = {
|
redirect_params = {"session_id": session.id, "status": session.status, "resultCode": session.result_code or ""}
|
||||||
"session_id": session.id,
|
|
||||||
"status": session.status,
|
|
||||||
"resultCode": session.result_code or "",
|
|
||||||
}
|
|
||||||
if session.remote_group_id:
|
if session.remote_group_id:
|
||||||
redirect_params["remote_group_id"] = 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}
|
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
|
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)):
|
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
|
asset_id_snapshot = asset.id
|
||||||
project_id_snapshot = asset.project_id
|
project_id_snapshot = asset.project_id
|
||||||
out = asset_to_out(asset)
|
out = asset_to_out(asset)
|
||||||
@@ -205,32 +206,34 @@ async def create_private_portrait_asset(project_id: str, payload: PrivatePortrai
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.get("/private-portrait/projects/{project_id}/assets", response_model=PrivatePortraitAssetListOut)
|
@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), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
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)
|
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)
|
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)):
|
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:
|
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()
|
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)
|
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)):
|
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)
|
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)
|
out = asset_to_out(asset)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return out
|
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)):
|
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
|
asset_id_snapshot = asset.id
|
||||||
project_id_snapshot = asset.project_id
|
project_id_snapshot = asset.project_id
|
||||||
await db.commit()
|
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)
|
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/private-portrait/selectable-assets", response_model=PrivatePortraitSelectableAssetListOut)
|
@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), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
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)
|
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)
|
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_TEMPLATE_CODE: str = "SMS_001"
|
||||||
SMS_MOCK: bool = True
|
SMS_MOCK: bool = True
|
||||||
|
|
||||||
|
# 火山引擎配置。
|
||||||
|
VOLC_ACCESS_KEY_ID: str = ""
|
||||||
|
VOLC_SECRET_ACCESS_KEY: str = ""
|
||||||
|
|
||||||
# 火山引擎短信配置。
|
# 火山引擎短信配置。
|
||||||
# SmsAccount=消息组ID,TemplateID=模板ID,Sign=短信签名内容。
|
# SmsAccount=消息组ID,TemplateID=模板ID,Sign=短信签名内容。
|
||||||
VOLC_SMS_ACCESS_KEY_ID: str = ""
|
VOLC_SMS_ACCESS_KEY_ID: str = ""
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
# 用户真人素材图片默认上限。users.private_portrait_image_limit = 0 表示关闭模块;>0 表示启用并限制总量。
|
# 用户私域人像素材默认上限。users.private_portrait_asset_limit = 0 表示关闭模块;>0 表示启用并限制总素材量。
|
||||||
PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT = 5
|
# 统计口径:真人 + 虚拟;图片 + 视频。音频当前业务暂不开放。
|
||||||
|
PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT = 5
|
||||||
|
|
||||||
# 火山 Ark 私域真人素材 ProjectName:火山侧项目空间固定使用 default,并快照到各业务表 remote_project_name。
|
# 火山 Ark 私域素材 ProjectName:火山侧项目空间固定使用 default,并快照到各业务表 remote_project_name。
|
||||||
# 用户/项目隔离依赖本地 project_id 和火山返回的 Asset Group ID,不再动态拼接 ProjectName。
|
# 用户/项目隔离依赖本地 project_id 和火山返回的 Asset Group ID,不再动态拼接 ProjectName。
|
||||||
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME = "default"
|
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_VERIFY_TYPE = "real_time"
|
||||||
PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE = "10000"
|
PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE = "10000"
|
||||||
PRIVATE_PORTRAIT_ASSET_URI_PREFIX = "asset://"
|
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"
|
ARK_PRIVATE_PORTRAIT_HOST = "ark.cn-beijing.volcengineapi.com"
|
||||||
|
|
||||||
PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS = 20
|
PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS = 20
|
||||||
|
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS = 30
|
||||||
PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT = 60
|
PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT = 60
|
||||||
|
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT = 120
|
||||||
PRIVATE_PORTRAIT_ASSET_POLL_BATCH_SIZE = 50
|
PRIVATE_PORTRAIT_ASSET_POLL_BATCH_SIZE = 50
|
||||||
PRIVATE_PORTRAIT_REMOTE_DELETE_RECOVERY_BATCH_SIZE = 50
|
PRIVATE_PORTRAIT_REMOTE_DELETE_RECOVERY_BATCH_SIZE = 50
|
||||||
PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES = 30
|
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] = {
|
PRIVATE_PORTRAIT_ACTION_QPS_LIMITS: dict[str, int] = {
|
||||||
"CreateVisualValidateSession": 3,
|
"CreateVisualValidateSession": 3,
|
||||||
"GetVisualValidateResult": 3,
|
"GetVisualValidateResult": 3,
|
||||||
|
"CreateAssetGroup": 10,
|
||||||
"CreateAsset": 1,
|
"CreateAsset": 1,
|
||||||
"ListAssetGroups": 10,
|
"ListAssetGroups": 10,
|
||||||
"ListAssets": 10,
|
"ListAssets": 10,
|
||||||
@@ -44,6 +51,7 @@ PRIVATE_PORTRAIT_ACTION_QPS_LIMITS: dict[str, int] = {
|
|||||||
class ArkPrivatePortraitAction(str, Enum):
|
class ArkPrivatePortraitAction(str, Enum):
|
||||||
CREATE_VISUAL_VALIDATE_SESSION = "CreateVisualValidateSession"
|
CREATE_VISUAL_VALIDATE_SESSION = "CreateVisualValidateSession"
|
||||||
GET_VISUAL_VALIDATE_RESULT = "GetVisualValidateResult"
|
GET_VISUAL_VALIDATE_RESULT = "GetVisualValidateResult"
|
||||||
|
CREATE_ASSET_GROUP = "CreateAssetGroup"
|
||||||
CREATE_ASSET = "CreateAsset"
|
CREATE_ASSET = "CreateAsset"
|
||||||
GET_ASSET = "GetAsset"
|
GET_ASSET = "GetAsset"
|
||||||
LIST_ASSETS = "ListAssets"
|
LIST_ASSETS = "ListAssets"
|
||||||
@@ -55,10 +63,17 @@ class ArkPrivatePortraitAction(str, Enum):
|
|||||||
DELETE_ASSET_GROUP = "DeleteAssetGroup"
|
DELETE_ASSET_GROUP = "DeleteAssetGroup"
|
||||||
|
|
||||||
|
|
||||||
|
class PrivatePortraitLibraryType(str, Enum):
|
||||||
|
REAL_PERSON = "real_person"
|
||||||
|
AIGC_VIRTUAL = "aigc_virtual"
|
||||||
|
|
||||||
|
|
||||||
class PrivatePortraitProjectStatus(str, Enum):
|
class PrivatePortraitProjectStatus(str, Enum):
|
||||||
VALIDATING = "validating"
|
VALIDATING = "validating"
|
||||||
ACTIVE = "active"
|
ACTIVE = "active"
|
||||||
VALIDATE_FAILED = "validate_failed"
|
VALIDATE_FAILED = "validate_failed"
|
||||||
|
CREATING_REMOTE_GROUP = "creating_remote_group"
|
||||||
|
CREATE_GROUP_FAILED = "create_group_failed"
|
||||||
DELETED = "deleted"
|
DELETED = "deleted"
|
||||||
|
|
||||||
|
|
||||||
@@ -72,6 +87,7 @@ class PrivatePortraitValidateSessionStatus(str, Enum):
|
|||||||
|
|
||||||
|
|
||||||
class PrivatePortraitAssetGroupStatus(str, Enum):
|
class PrivatePortraitAssetGroupStatus(str, Enum):
|
||||||
|
CREATING = "creating"
|
||||||
ACTIVE = "active"
|
ACTIVE = "active"
|
||||||
LOCAL_DELETED = "local_deleted"
|
LOCAL_DELETED = "local_deleted"
|
||||||
REMOTE_DELETED = "remote_deleted"
|
REMOTE_DELETED = "remote_deleted"
|
||||||
@@ -92,7 +108,13 @@ class PrivatePortraitAssetStatus(str, Enum):
|
|||||||
class PrivatePortraitAssetType(str, Enum):
|
class PrivatePortraitAssetType(str, Enum):
|
||||||
IMAGE = "Image"
|
IMAGE = "Image"
|
||||||
VIDEO = "Video"
|
VIDEO = "Video"
|
||||||
AUDIO = "Audio"
|
AUDIO = "Audio" # 火山支持,但当前业务暂不开放。
|
||||||
|
|
||||||
|
|
||||||
|
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES = {
|
||||||
|
PrivatePortraitAssetType.IMAGE.value,
|
||||||
|
PrivatePortraitAssetType.VIDEO.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class PrivatePortraitRemoteDeleteStatus(str, Enum):
|
class PrivatePortraitRemoteDeleteStatus(str, Enum):
|
||||||
@@ -125,9 +147,19 @@ class PrivatePortraitEventSource(str, Enum):
|
|||||||
|
|
||||||
|
|
||||||
class PrivatePortraitEventType(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_CREATE = "PROJECT_CREATE"
|
||||||
PROJECT_UPDATE = "PROJECT_UPDATE"
|
PROJECT_UPDATE = "PROJECT_UPDATE"
|
||||||
PROJECT_DELETE = "PROJECT_DELETE"
|
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 = "VALIDATE_SESSION_CREATE"
|
||||||
VALIDATE_SESSION_CREATE_FAILED = "VALIDATE_SESSION_CREATE_FAILED"
|
VALIDATE_SESSION_CREATE_FAILED = "VALIDATE_SESSION_CREATE_FAILED"
|
||||||
VALIDATE_CALLBACK_RECEIVED = "VALIDATE_CALLBACK_RECEIVED"
|
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_SUCCESS = "PROJECT_DELETE_REMOTE_SUCCESS"
|
||||||
PROJECT_DELETE_REMOTE_FAILED = "PROJECT_DELETE_REMOTE_FAILED"
|
PROJECT_DELETE_REMOTE_FAILED = "PROJECT_DELETE_REMOTE_FAILED"
|
||||||
|
|
||||||
|
|
||||||
TASK_DISPATCH_SUCCESS = "TASK_DISPATCH_SUCCESS"
|
TASK_DISPATCH_SUCCESS = "TASK_DISPATCH_SUCCESS"
|
||||||
TASK_DISPATCH_FAILED = "TASK_DISPATCH_FAILED"
|
TASK_DISPATCH_FAILED = "TASK_DISPATCH_FAILED"
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.enums.private_portrait import (
|
from app.enums.private_portrait import (
|
||||||
PrivatePortraitAssetStatus,
|
PrivatePortraitAssetStatus,
|
||||||
PrivatePortraitAssetType,
|
PrivatePortraitAssetType,
|
||||||
|
PrivatePortraitLibraryType,
|
||||||
PrivatePortraitRemoteDeleteStatus,
|
PrivatePortraitRemoteDeleteStatus,
|
||||||
)
|
)
|
||||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||||
@@ -19,10 +20,13 @@ class PrivatePortraitAsset(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
__tablename__ = "private_portrait_assets"
|
__tablename__ = "private_portrait_assets"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("uq_private_portrait_assets_remote_asset_id", "remote_asset_id", unique=True),
|
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_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_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_group_status_created", "group_id", "status", "created_at"),
|
||||||
Index("idx_private_portrait_assets_remote_project_name", "remote_project_name"),
|
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(
|
Index(
|
||||||
"idx_private_portrait_assets_next_poll_status",
|
"idx_private_portrait_assets_next_poll_status",
|
||||||
"next_poll_at",
|
"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)
|
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)
|
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)
|
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_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_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)
|
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)
|
preview_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
remote_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)
|
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(
|
status: Mapped[str] = mapped_column(
|
||||||
String(32),
|
String(32),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ from sqlalchemy import DateTime, ForeignKey, Index, String, Text, text
|
|||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.enums.private_portrait import (
|
from app.enums.private_portrait import (
|
||||||
PRIVATE_PORTRAIT_GROUP_TYPE,
|
PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE,
|
||||||
PrivatePortraitAssetGroupStatus,
|
PrivatePortraitAssetGroupStatus,
|
||||||
|
PrivatePortraitLibraryType,
|
||||||
PrivatePortraitRemoteDeleteStatus,
|
PrivatePortraitRemoteDeleteStatus,
|
||||||
)
|
)
|
||||||
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||||
@@ -20,9 +21,11 @@ class PrivatePortraitAssetGroup(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("uq_private_portrait_asset_groups_remote_group_id", "remote_group_id", unique=True),
|
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_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_project_status", "project_id", "status"),
|
||||||
Index("idx_private_portrait_asset_groups_remote_delete_status", "remote_delete_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_remote_project_name", "remote_project_name"),
|
||||||
|
Index("idx_private_portrait_asset_groups_library_type", "library_type"),
|
||||||
Index(
|
Index(
|
||||||
"uq_private_portrait_asset_groups_one_active_project",
|
"uq_private_portrait_asset_groups_one_active_project",
|
||||||
"project_id",
|
"project_id",
|
||||||
@@ -34,10 +37,17 @@ class PrivatePortraitAssetGroup(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
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)
|
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)
|
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_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_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)
|
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(
|
status: Mapped[str] = mapped_column(
|
||||||
String(32),
|
String(32),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|||||||
@@ -5,15 +5,16 @@ from datetime import datetime
|
|||||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
|
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
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
|
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin):
|
class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin):
|
||||||
"""用户本地真人素材项目组。remote_project_name 是火山 ProjectName 快照。"""
|
"""用户本地私域人像素材项目组。remote_project_name 是火山 ProjectName 快照。"""
|
||||||
|
|
||||||
__tablename__ = "private_portrait_projects"
|
__tablename__ = "private_portrait_projects"
|
||||||
__table_args__ = (
|
__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_status_created", "user_id", "status", "created_at"),
|
||||||
Index(
|
Index(
|
||||||
"idx_private_portrait_projects_user_deleted",
|
"idx_private_portrait_projects_user_deleted",
|
||||||
@@ -22,10 +23,19 @@ class PrivatePortraitProject(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
postgresql_where=text("deleted_at IS NULL"),
|
postgresql_where=text("deleted_at IS NULL"),
|
||||||
),
|
),
|
||||||
Index("idx_private_portrait_projects_remote_project_name", "remote_project_name"),
|
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)
|
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)
|
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: 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")
|
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 快照")
|
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_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")
|
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_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)
|
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)
|
allowed_menus: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
# 真人素材库图片总量限制。0 表示关闭真人素材模块;>0 表示启用并限制用户所有真人素材图片总量。
|
# 私域人像素材总量限制。0 表示关闭模块;>0 表示启用并限制真人/虚拟、图片/视频素材总量。
|
||||||
private_portrait_image_limit: Mapped[int] = mapped_column(
|
private_portrait_asset_limit: Mapped[int] = mapped_column(
|
||||||
Integer, default=5, server_default="5", nullable=False
|
Integer, default=5, server_default="5", nullable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class AdminUserOut(BaseModel):
|
|||||||
last_login_at: NaiveDatetimeOptional = None
|
last_login_at: NaiveDatetimeOptional = None
|
||||||
allowed_menus: list | None = None
|
allowed_menus: list | None = None
|
||||||
resource_capacity: ResourceCapacityUsageOut | None = None
|
resource_capacity: ResourceCapacityUsageOut | None = None
|
||||||
private_portrait_image_limit: int = 5
|
private_portrait_asset_limit: int = 5
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ class CreateUserRequest(BaseModel):
|
|||||||
user_type: str = Field(default="frontend", pattern="^(frontend|admin)$")
|
user_type: str = Field(default="frontend", pattern="^(frontend|admin)$")
|
||||||
frontend_user_kind: str = Field(default="external", pattern="^(internal|external)$")
|
frontend_user_kind: str = Field(default="external", pattern="^(internal|external)$")
|
||||||
allowed_menus: list | None = None
|
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):
|
class UpdateFrontendUserKindRequest(BaseModel):
|
||||||
|
|||||||
@@ -4,36 +4,67 @@ from typing import Any
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
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
|
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):
|
class PrivatePortraitConfigOut(BaseModel):
|
||||||
enabled: bool
|
enabled: bool = Field(..., description="是否启用私域人像素材库。asset_limit > 0 表示启用。")
|
||||||
image_limit: int
|
asset_limit: int = Field(..., description="私域人像素材总量限制:真人/虚拟共用,图片/视频共用,0 表示关闭。")
|
||||||
used_image_count: int
|
used_asset_count: int = Field(..., description="当前占用额度的素材数量。统计 creating/Processing/Active 的 Image/Video。")
|
||||||
remaining_image_count: int
|
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):
|
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):
|
class PrivatePortraitProjectCreate(BaseModel):
|
||||||
name: str = Field(..., min_length=1, max_length=128)
|
name: str = Field(..., min_length=1, max_length=128, description="项目组名称。")
|
||||||
description: str | None = Field(None, max_length=2000)
|
description: str | None = Field(None, max_length=2000, description="项目组描述。")
|
||||||
callback_redirect_url: str | None = Field(None, 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):
|
class PrivatePortraitProjectUpdate(BaseModel):
|
||||||
name: str | None = Field(None, min_length=1, max_length=128)
|
name: str | None = Field(None, min_length=1, max_length=128, description="项目组名称。")
|
||||||
description: str | None = Field(None, max_length=2000)
|
description: str | None = Field(None, max_length=2000, description="项目组描述。")
|
||||||
status: str | None = None
|
status: str | None = Field(None, description="项目状态。普通前端不建议手动变更,仅管理/排查使用。")
|
||||||
|
|
||||||
|
|
||||||
class PrivatePortraitProjectOut(BaseModel):
|
class PrivatePortraitProjectOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
user_id: str | None = None
|
user_id: str | None = None
|
||||||
|
library_type: str = Field(default=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||||
name: str
|
name: str
|
||||||
name_slug: str | None = None
|
name_slug: str | None = None
|
||||||
remote_project_name: str | None = None
|
remote_project_name: str | None = None
|
||||||
@@ -41,7 +72,11 @@ class PrivatePortraitProjectOut(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
asset_group_count: int = 0
|
asset_group_count: int = 0
|
||||||
asset_count: int = 0
|
asset_count: int = 0
|
||||||
|
image_asset_count: int = 0
|
||||||
|
video_asset_count: int = 0
|
||||||
active_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
|
last_used_at: NaiveDatetimeOptional = None
|
||||||
created_at: NaiveDatetimeOptional = None
|
created_at: NaiveDatetimeOptional = None
|
||||||
updated_at: NaiveDatetimeOptional = None
|
updated_at: NaiveDatetimeOptional = None
|
||||||
@@ -81,18 +116,17 @@ class PrivatePortraitValidateSessionOut(BaseModel):
|
|||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class PrivatePortraitProjectCreateWithValidateOut(BaseModel):
|
class PrivatePortraitProjectCreateWithValidateOut(BaseModel):
|
||||||
project: PrivatePortraitProjectOut
|
project: PrivatePortraitProjectOut
|
||||||
validate_session: PrivatePortraitValidateSessionOut
|
validate_session: PrivatePortraitValidateSessionOut
|
||||||
poll_interval_ms: int = Field(default=2000, description="PC 端轮询认证状态的建议间隔,单位毫秒")
|
poll_interval_ms: int = Field(default=2000, description="PC 端轮询认证状态的建议间隔,单位毫秒。")
|
||||||
|
|
||||||
|
|
||||||
class PrivatePortraitAssetGroupOut(BaseModel):
|
class PrivatePortraitAssetGroupOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
user_id: str | None = None
|
user_id: str | None = None
|
||||||
project_id: str
|
project_id: str
|
||||||
|
library_type: str
|
||||||
remote_group_id: str
|
remote_group_id: str
|
||||||
remote_group_name: str | None = None
|
remote_group_name: str | None = None
|
||||||
remote_project_name: str
|
remote_project_name: str
|
||||||
@@ -108,16 +142,22 @@ class PrivatePortraitAssetGroupOut(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class PrivatePortraitAssetCreate(BaseModel):
|
class PrivatePortraitAssetCreate(BaseModel):
|
||||||
url: str = Field(..., min_length=1, description="已上传到本系统且可公网访问的素材 URL")
|
url: str = Field(..., min_length=1, description="已上传到本系统且可公网访问的素材 URL。支持图片/视频,后端会转换公网地址后调用火山 CreateAsset。")
|
||||||
asset_type: str = Field(default=PrivatePortraitAssetType.IMAGE.value)
|
asset_type: str = Field(default=PrivatePortraitAssetType.IMAGE.value, description="素材类型。当前业务仅开放 Image / Video,Audio 暂不开放。")
|
||||||
name: str | None = Field(None, max_length=256)
|
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")
|
@field_validator("asset_type")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_asset_type(cls, v: str) -> str:
|
def validate_asset_type(cls, v: str) -> str:
|
||||||
value = v or PrivatePortraitAssetType.IMAGE.value
|
value = v or PrivatePortraitAssetType.IMAGE.value
|
||||||
if value not in {item.value for item in PrivatePortraitAssetType}:
|
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
|
return value
|
||||||
|
|
||||||
|
|
||||||
@@ -127,6 +167,7 @@ class PrivatePortraitAssetOut(BaseModel):
|
|||||||
project_id: str
|
project_id: str
|
||||||
project_name: str | None = None
|
project_name: str | None = None
|
||||||
group_id: str
|
group_id: str
|
||||||
|
library_type: str
|
||||||
remote_group_id: str
|
remote_group_id: str
|
||||||
remote_asset_id: str | None = None
|
remote_asset_id: str | None = None
|
||||||
remote_project_name: str | None = None
|
remote_project_name: str | None = None
|
||||||
@@ -134,10 +175,16 @@ class PrivatePortraitAssetOut(BaseModel):
|
|||||||
name: str | None = None
|
name: str | None = None
|
||||||
source_url: str
|
source_url: str
|
||||||
preview_url: str | None = None
|
preview_url: str | None = None
|
||||||
|
display_url: str | None = None
|
||||||
|
provider_url: str | None = None
|
||||||
remote_url: str | None = None
|
remote_url: str | None = None
|
||||||
remote_url_expired_at: NaiveDatetimeOptional = 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
|
status: str
|
||||||
moderation: dict[str, Any] | None = None
|
moderation: Any = None
|
||||||
last_poll_at: NaiveDatetimeOptional = None
|
last_poll_at: NaiveDatetimeOptional = None
|
||||||
next_poll_at: NaiveDatetimeOptional = None
|
next_poll_at: NaiveDatetimeOptional = None
|
||||||
poll_count: int = 0
|
poll_count: int = 0
|
||||||
@@ -162,10 +209,15 @@ class PrivatePortraitSelectableAssetOut(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
project_id: str
|
project_id: str
|
||||||
project_name: str
|
project_name: str
|
||||||
|
library_type: str
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
asset_type: str
|
asset_type: str
|
||||||
preview_url: str | None = None
|
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
|
created_at: NaiveDatetimeOptional = None
|
||||||
|
|
||||||
|
|
||||||
@@ -178,4 +230,32 @@ class PrivatePortraitSelectableAssetListOut(BaseModel):
|
|||||||
|
|
||||||
class PrivatePortraitDeleteOut(BaseModel):
|
class PrivatePortraitDeleteOut(BaseModel):
|
||||||
success: bool = True
|
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
|
allowed_menus: list | None = None
|
||||||
must_set_password: bool = False
|
must_set_password: bool = False
|
||||||
resource_capacity: ResourceCapacityUsageOut | None = None
|
resource_capacity: ResourceCapacityUsageOut | None = None
|
||||||
private_portrait_image_limit: int = 5
|
private_portrait_asset_limit: int = 5
|
||||||
team_id: str | None = None
|
team_id: str | None = None
|
||||||
team_name: str | None = None
|
team_name: str | None = None
|
||||||
is_team_manager: bool = False
|
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:
|
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):
|
def __init__(self, *, for_celery: bool = False):
|
||||||
self.ak = ak or settings.VOLC_SMS_ACCESS_KEY_ID
|
self.ak = settings.VOLC_ACCESS_KEY_ID
|
||||||
self.sk = sk or settings.VOLC_SMS_SECRET_ACCESS_KEY
|
self.sk = settings.VOLC_SECRET_ACCESS_KEY
|
||||||
self.for_celery = for_celery
|
self.for_celery = for_celery
|
||||||
if not self.ak or not self.sk:
|
if not self.ak or not self.sk:
|
||||||
raise ArkPrivateAssetClientError("火山 AK/SK 未配置:VOLC_SMS_ACCESS_KEY_ID / VOLC_SMS_SECRET_ACCESS_KEY")
|
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]:
|
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})
|
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]:
|
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}
|
payload: dict[str, Any] = {"GroupId": group_id, "URL": url, "AssetType": asset_type, "ProjectName": project_name}
|
||||||
if name:
|
if name:
|
||||||
|
|||||||
@@ -6,33 +6,42 @@ from typing import Any
|
|||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import and_, func, select, update
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.enums.private_portrait import (
|
from app.enums.private_portrait import (
|
||||||
PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS,
|
PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS,
|
||||||
PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT,
|
PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT,
|
||||||
PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT,
|
PRIVATE_PORTRAIT_ASSET_URI_PREFIX,
|
||||||
PRIVATE_PORTRAIT_GROUP_TYPE,
|
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
|
||||||
|
PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE,
|
||||||
PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE,
|
PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE,
|
||||||
PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES,
|
PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES,
|
||||||
|
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS,
|
||||||
|
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT,
|
||||||
PrivatePortraitAssetGroupStatus,
|
PrivatePortraitAssetGroupStatus,
|
||||||
PrivatePortraitAssetStatus,
|
PrivatePortraitAssetStatus,
|
||||||
PrivatePortraitAssetType,
|
PrivatePortraitAssetType,
|
||||||
PrivatePortraitEventSource,
|
PrivatePortraitEventSource,
|
||||||
PrivatePortraitEventStatus,
|
PrivatePortraitEventStatus,
|
||||||
PrivatePortraitEventType,
|
PrivatePortraitEventType,
|
||||||
|
PrivatePortraitLibraryType,
|
||||||
PrivatePortraitProjectStatus,
|
PrivatePortraitProjectStatus,
|
||||||
PrivatePortraitRemoteDeleteStatus,
|
PrivatePortraitRemoteDeleteStatus,
|
||||||
PrivatePortraitValidateSessionStatus,
|
PrivatePortraitValidateSessionStatus,
|
||||||
)
|
)
|
||||||
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject, PrivatePortraitValidateSession
|
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject, PrivatePortraitValidateSession
|
||||||
from app.models.user import User
|
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, PrivatePortraitSelectableAssetOut, PrivatePortraitValidateSessionOut
|
||||||
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, PrivatePortraitConfigOut, PrivatePortraitSelectableAssetOut, PrivatePortraitValidateSessionOut
|
|
||||||
from app.services.operation_log_service import log_operation_error, log_operation_event
|
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.ark_client import ArkPrivateAssetClient
|
||||||
from app.services.private_portrait.project_service import get_user_project, refresh_project_counters
|
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
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
DOMAIN = "private_portrait"
|
DOMAIN = "private_portrait"
|
||||||
@@ -53,7 +62,6 @@ def _loads(data: str | None) -> Any:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _exception_message(exc: Exception) -> str:
|
def _exception_message(exc: Exception) -> str:
|
||||||
if isinstance(exc, HTTPException):
|
if isinstance(exc, HTTPException):
|
||||||
detail = exc.detail
|
detail = exc.detail
|
||||||
@@ -65,7 +73,7 @@ def _exception_message(exc: Exception) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _public_url(url: str) -> 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 url
|
||||||
return f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}"
|
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]
|
return f"{user_id}-{safe_name}"[:128]
|
||||||
|
|
||||||
|
|
||||||
async def get_user_private_portrait_config(db: AsyncSession, *, user_id: str) -> PrivatePortraitConfigOut:
|
def _asset_display_url(asset: PrivatePortraitAsset) -> str | None:
|
||||||
user = (await db.execute(select(User).where(User.id == user_id).limit(1))).scalar_one_or_none()
|
return asset.preview_url or asset.remote_url or asset.source_url 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)
|
|
||||||
|
|
||||||
|
|
||||||
async def set_user_private_portrait_limit(db: AsyncSession, *, user_id: str, limit: int) -> User:
|
def _provider_url(asset: PrivatePortraitAsset) -> str | None:
|
||||||
user = (await db.execute(select(User).where(User.id == user_id).limit(1))).scalar_one_or_none()
|
return f"{PRIVATE_PORTRAIT_ASSET_URI_PREFIX}{asset.remote_asset_id}" if asset.remote_asset_id else None
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
|
||||||
user.private_portrait_image_limit = max(0, int(limit))
|
|
||||||
await db.flush()
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
async def count_user_counting_image_assets(db: AsyncSession, *, user_id: str) -> int:
|
def _poll_interval_seconds(asset_type: str) -> int:
|
||||||
statuses = [PrivatePortraitAssetStatus.CREATING.value, PrivatePortraitAssetStatus.PROCESSING.value, PrivatePortraitAssetStatus.ACTIVE.value]
|
if asset_type == PrivatePortraitAssetType.VIDEO.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 PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS
|
||||||
return int(total or 0)
|
return PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS
|
||||||
|
|
||||||
|
|
||||||
async def _lock_user_for_upload(db: AsyncSession, *, user_id: str) -> User:
|
def _poll_max_count(asset_type: str) -> int:
|
||||||
# 锁 users 行,避免并发绕过用户总量限制。SQLite 会忽略 FOR UPDATE,不影响本地开发。
|
if asset_type == PrivatePortraitAssetType.VIDEO.value:
|
||||||
user = (await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1))).scalar_one_or_none()
|
return PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT
|
||||||
if not user:
|
return PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
|
||||||
return user
|
|
||||||
|
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:
|
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:
|
def asset_to_out(asset: PrivatePortraitAsset, *, project_name: str | None = None, include_user: bool = False) -> PrivatePortraitAssetOut:
|
||||||
|
display_url = _asset_display_url(asset)
|
||||||
return PrivatePortraitAssetOut(
|
return PrivatePortraitAssetOut(
|
||||||
id=asset.id,
|
id=asset.id,
|
||||||
user_id=asset.user_id if include_user else None,
|
user_id=asset.user_id if include_user else None,
|
||||||
project_id=asset.project_id,
|
project_id=asset.project_id,
|
||||||
project_name=project_name,
|
project_name=project_name,
|
||||||
group_id=asset.group_id,
|
group_id=asset.group_id,
|
||||||
|
library_type=asset.library_type,
|
||||||
remote_group_id=asset.remote_group_id,
|
remote_group_id=asset.remote_group_id,
|
||||||
remote_asset_id=asset.remote_asset_id,
|
remote_asset_id=asset.remote_asset_id,
|
||||||
remote_project_name=asset.remote_project_name,
|
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,
|
name=asset.name,
|
||||||
source_url=asset.source_url,
|
source_url=asset.source_url,
|
||||||
preview_url=asset.preview_url,
|
preview_url=asset.preview_url,
|
||||||
|
display_url=display_url,
|
||||||
|
provider_url=_provider_url(asset),
|
||||||
remote_url=asset.remote_url,
|
remote_url=asset.remote_url,
|
||||||
remote_url_expired_at=asset.remote_url_expired_at,
|
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,
|
status=asset.status,
|
||||||
moderation=_loads(asset.moderation_json),
|
moderation=_loads(asset.moderation_json),
|
||||||
last_poll_at=asset.last_poll_at,
|
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:
|
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 (
|
return (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(PrivatePortraitAssetGroup)
|
select(PrivatePortraitAssetGroup)
|
||||||
.where(
|
.where(*filters)
|
||||||
PrivatePortraitAssetGroup.project_id == project_id,
|
|
||||||
PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value,
|
|
||||||
PrivatePortraitAssetGroup.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
.order_by(PrivatePortraitAssetGroup.created_at.desc())
|
.order_by(PrivatePortraitAssetGroup.created_at.desc())
|
||||||
.limit(1)
|
.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:
|
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:
|
if active_group or project.status == PrivatePortraitProjectStatus.ACTIVE.value:
|
||||||
raise HTTPException(status_code=409, detail="该真人素材项目已完成认证,不能重复认证")
|
raise HTTPException(status_code=409, detail="该真人素材项目已完成认证,不能重复认证")
|
||||||
|
|
||||||
@@ -206,12 +222,7 @@ async def _ensure_project_can_validate(db: AsyncSession, *, project: PrivatePort
|
|||||||
select(PrivatePortraitValidateSession)
|
select(PrivatePortraitValidateSession)
|
||||||
.where(
|
.where(
|
||||||
PrivatePortraitValidateSession.project_id == project.id,
|
PrivatePortraitValidateSession.project_id == project.id,
|
||||||
PrivatePortraitValidateSession.status.in_(
|
PrivatePortraitValidateSession.status.in_([PrivatePortraitValidateSessionStatus.CREATED.value, PrivatePortraitValidateSessionStatus.CALLBACK_SUCCESS.value]),
|
||||||
[
|
|
||||||
PrivatePortraitValidateSessionStatus.CREATED.value,
|
|
||||||
PrivatePortraitValidateSessionStatus.CALLBACK_SUCCESS.value,
|
|
||||||
]
|
|
||||||
),
|
|
||||||
PrivatePortraitValidateSession.expired_at.is_not(None),
|
PrivatePortraitValidateSession.expired_at.is_not(None),
|
||||||
PrivatePortraitValidateSession.expired_at > now,
|
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:
|
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)
|
reusable_session = await _ensure_project_can_validate(db, project=project)
|
||||||
if reusable_session:
|
if reusable_session:
|
||||||
return 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.h5_link = resp.get("H5Link") or resp.get("h5Link")
|
||||||
session.raw_response_json = _json(resp)
|
session.raw_response_json = _json(resp)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
# created_at / updated_at 来自数据库默认值或 onupdate,flush 后可能处于 expired 状态。
|
|
||||||
# 在 async SQLAlchemy 下,响应转换时同步读取 expired 字段会触发 MissingGreenlet。
|
|
||||||
await db.refresh(session)
|
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
|
return session
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
|
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)
|
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
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def get_validate_session(db: AsyncSession, *, user_id: str | None, session_id: str) -> PrivatePortraitValidateSession:
|
async def get_validate_session(db: AsyncSession, *, user_id: str | None, session_id: str) -> PrivatePortraitValidateSession:
|
||||||
filters = [PrivatePortraitValidateSession.id == session_id]
|
filters = [PrivatePortraitValidateSession.id == session_id]
|
||||||
if user_id is not None:
|
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)
|
raise HTTPException(status_code=400, detail=session.error_message)
|
||||||
|
|
||||||
try:
|
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:
|
if existing_group:
|
||||||
session.remote_group_id = existing_group.remote_group_id
|
session.remote_group_id = existing_group.remote_group_id
|
||||||
session.status = PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value
|
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(),
|
id=generate_id(),
|
||||||
user_id=session.user_id,
|
user_id=session.user_id,
|
||||||
project_id=session.project_id,
|
project_id=session.project_id,
|
||||||
|
library_type=PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||||
remote_group_id=group_id,
|
remote_group_id=group_id,
|
||||||
remote_group_name=remote_group_name,
|
remote_group_name=remote_group_name,
|
||||||
remote_project_name=session.remote_project_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,
|
status=PrivatePortraitAssetGroupStatus.ACTIVE.value,
|
||||||
raw_response_json=_json(resp),
|
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 refresh_project_counters(db, [session.project_id])
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(session)
|
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
|
return session
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
|
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)
|
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
|
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:
|
if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
|
||||||
raise HTTPException(status_code=400, detail="请先完成真人授权认证,再上传素材")
|
detail = "请先完成真人授权认证,再上传素材" if project.library_type == PrivatePortraitLibraryType.REAL_PERSON.value else "虚拟人像素材组尚未创建成功,不能上传素材"
|
||||||
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))
|
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()
|
group = result.scalar_one_or_none()
|
||||||
if not group:
|
if not group:
|
||||||
raise HTTPException(status_code=400, detail="请先完成真人授权认证,再上传素材")
|
raise HTTPException(status_code=400, detail="项目没有可用的远程素材组")
|
||||||
return group
|
return group
|
||||||
|
|
||||||
|
|
||||||
async def create_asset(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitAssetCreate) -> PrivatePortraitAsset:
|
async def create_asset(
|
||||||
project = await get_user_project(db, user_id=user_id, project_id=project_id)
|
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:
|
if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
|
||||||
raise HTTPException(status_code=400, detail="项目正在真人认证或认证未通过,不能上传素材")
|
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} 张图片,请删除已有素材后再上传")
|
|
||||||
|
|
||||||
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)
|
public_url = _public_url(payload.url)
|
||||||
asset = PrivatePortraitAsset(
|
asset = PrivatePortraitAsset(
|
||||||
id=generate_id(),
|
id=generate_id(),
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
group_id=group.id,
|
group_id=group.id,
|
||||||
|
library_type=project.library_type,
|
||||||
remote_group_id=group.remote_group_id,
|
remote_group_id=group.remote_group_id,
|
||||||
remote_project_name=project.remote_project_name,
|
remote_project_name=project.remote_project_name,
|
||||||
asset_type=payload.asset_type,
|
asset_type=payload.asset_type,
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
source_url=public_url,
|
source_url=public_url,
|
||||||
preview_url=payload.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,
|
status=PrivatePortraitAssetStatus.CREATING.value,
|
||||||
)
|
)
|
||||||
db.add(asset)
|
db.add(asset)
|
||||||
await db.flush()
|
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:
|
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_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")
|
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)
|
now = datetime.now(timezone.utc)
|
||||||
asset.remote_asset_id = remote_asset_id
|
asset.remote_asset_id = remote_asset_id
|
||||||
asset.status = PrivatePortraitAssetStatus.PROCESSING.value
|
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)
|
asset.raw_response_json = _json(remote_resp)
|
||||||
await refresh_project_counters(db, [project.id])
|
await refresh_project_counters(db, [project.id])
|
||||||
await db.flush()
|
await db.flush()
|
||||||
# created_at / updated_at 来自数据库默认值或 onupdate,flush 后可能处于 expired 状态。
|
|
||||||
# 在 async SQLAlchemy 下,响应转换时同步读取 expired 字段会触发 MissingGreenlet。
|
|
||||||
await db.refresh(asset)
|
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
|
return asset
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
asset.status = PrivatePortraitAssetStatus.FAILED.value
|
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)
|
filters.append(PrivatePortraitAsset.user_id == user_id)
|
||||||
asset = (await db.execute(select(PrivatePortraitAsset).where(*filters).limit(1))).scalar_one_or_none()
|
asset = (await db.execute(select(PrivatePortraitAsset).where(*filters).limit(1))).scalar_one_or_none()
|
||||||
if not asset:
|
if not asset:
|
||||||
raise HTTPException(status_code=404, detail="真人素材不存在")
|
raise HTTPException(status_code=404, detail="私域人像素材不存在")
|
||||||
if asset.deleted_at is not None:
|
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:
|
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
|
source = PrivatePortraitEventSource.CELERY.value if user_id is None else PrivatePortraitEventSource.API.value
|
||||||
log_operation_event(
|
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,
|
user_id=asset.user_id,
|
||||||
project_id=asset.project_id,
|
project_id=asset.project_id,
|
||||||
asset_id=asset.id,
|
asset_id=asset.id,
|
||||||
detail={
|
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},
|
||||||
"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,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
try:
|
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)
|
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.status = status
|
||||||
asset.remote_url = remote_resp.get("URL") or remote_resp.get("url") or asset.remote_url
|
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"))
|
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.status = PrivatePortraitAssetStatus.FAILED.value
|
||||||
asset.error_message = "素材入库轮询超时"
|
asset.error_message = "素材入库轮询超时"
|
||||||
asset.next_poll_at = None
|
asset.next_poll_at = None
|
||||||
log_operation_event(
|
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)
|
||||||
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,
|
|
||||||
)
|
|
||||||
elif asset.status == PrivatePortraitAssetStatus.PROCESSING.value:
|
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:
|
else:
|
||||||
asset.next_poll_at = None
|
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 refresh_project_counters(db, [asset.project_id])
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(asset)
|
await db.refresh(asset)
|
||||||
log_operation_event(
|
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})
|
||||||
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},
|
|
||||||
)
|
|
||||||
return asset
|
return asset
|
||||||
except Exception as exc:
|
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)
|
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
|
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 = max(1, page)
|
||||||
page_size = min(max(1, page_size), 100)
|
page_size = min(max(1, page_size), 100)
|
||||||
filters = [PrivatePortraitAsset.deleted_at.is_(None)]
|
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)
|
filters.append(PrivatePortraitAsset.user_id == user_id)
|
||||||
if project_id:
|
if project_id:
|
||||||
filters.append(PrivatePortraitAsset.project_id == 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:
|
if status:
|
||||||
filters.append(PrivatePortraitAsset.status == status)
|
filters.append(PrivatePortraitAsset.status == status)
|
||||||
if keyword:
|
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
|
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]:
|
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)
|
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, ""), 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
|
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:
|
async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str, library_type: str | None = None) -> 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()
|
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:
|
if not asset:
|
||||||
raise HTTPException(status_code=404, detail="真人素材不存在")
|
raise HTTPException(status_code=404, detail="私域人像素材不存在")
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
asset.deleted_at = now
|
asset.deleted_at = now
|
||||||
asset.status = PrivatePortraitAssetStatus.LOCAL_DELETED.value
|
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 refresh_project_counters(db, [asset.project_id])
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(asset)
|
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
|
return asset
|
||||||
|
|
||||||
|
|
||||||
async def delete_asset_remote(db: AsyncSession, *, asset_id: str) -> None:
|
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()
|
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id).limit(1))).scalar_one_or_none()
|
||||||
if not asset:
|
if not asset:
|
||||||
log_operation_event(
|
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="远程删除跳过:本地素材不存在")
|
||||||
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
|
return
|
||||||
if not asset.remote_asset_id:
|
if not asset.remote_asset_id:
|
||||||
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
|
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
|
||||||
asset.remote_delete_error = None
|
asset.remote_delete_error = None
|
||||||
await db.flush()
|
await db.flush()
|
||||||
log_operation_event(
|
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")
|
||||||
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
|
return
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
log_operation_event(
|
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})
|
||||||
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},
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
await ArkPrivateAssetClient(for_celery=True).delete_asset(project_name=asset.remote_project_name, asset_id=asset.remote_asset_id)
|
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.status = PrivatePortraitAssetStatus.REMOTE_DELETED.value
|
||||||
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
|
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
|
||||||
asset.remote_deleted_at = now
|
asset.remote_deleted_at = now
|
||||||
asset.remote_delete_error = None
|
asset.remote_delete_error = None
|
||||||
log_operation_event(
|
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})
|
||||||
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},
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
asset.status = PrivatePortraitAssetStatus.DELETE_FAILED.value
|
asset.status = PrivatePortraitAssetStatus.DELETE_FAILED.value
|
||||||
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.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_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
|
||||||
group.remote_delete_error = None
|
group.remote_delete_error = None
|
||||||
await db.flush()
|
await db.flush()
|
||||||
log_operation_event(
|
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")
|
||||||
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
|
return
|
||||||
client = client or ArkPrivateAssetClient(for_celery=True)
|
client = client or ArkPrivateAssetClient(for_celery=True)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
log_operation_event(
|
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})
|
||||||
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},
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
await client.delete_asset_group(project_name=group.remote_project_name, group_id=group.remote_group_id)
|
await client.delete_asset_group(project_name=group.remote_project_name, group_id=group.remote_group_id)
|
||||||
group.status = PrivatePortraitAssetGroupStatus.REMOTE_DELETED.value
|
group.status = PrivatePortraitAssetGroupStatus.REMOTE_DELETED.value
|
||||||
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
|
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
|
||||||
group.remote_deleted_at = now
|
group.remote_deleted_at = now
|
||||||
group.remote_delete_error = None
|
group.remote_delete_error = None
|
||||||
log_operation_event(
|
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})
|
||||||
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},
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
group.status = PrivatePortraitAssetGroupStatus.DELETE_FAILED.value
|
group.status = PrivatePortraitAssetGroupStatus.DELETE_FAILED.value
|
||||||
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.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:
|
async def delete_project_remote(db: AsyncSession, *, project_id: str) -> None:
|
||||||
log_operation_event(
|
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="开始远程删除私域人像素材项目资源")
|
||||||
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))
|
rows = await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.project_id == project_id))
|
||||||
for asset in rows.scalars().all():
|
for asset in rows.scalars().all():
|
||||||
await delete_asset_remote(db, asset_id=asset.id)
|
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)
|
client = ArkPrivateAssetClient(for_celery=True)
|
||||||
for group in groups.scalars().all():
|
for group in groups.scalars().all():
|
||||||
await _delete_asset_group_remote(db, group=group, client=client)
|
await _delete_asset_group_remote(db, group=group, client=client)
|
||||||
log_operation_event(
|
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="远程删除私域人像素材项目资源完成")
|
||||||
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()
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
@@ -707,13 +669,7 @@ async def poll_due_assets_once(db: AsyncSession, *, limit: int) -> int:
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
ids = [row[0] for row in rows.all()]
|
ids = [row[0] for row in rows.all()]
|
||||||
log_operation_event(
|
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)})
|
||||||
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
|
success_count = 0
|
||||||
failed_count = 0
|
failed_count = 0
|
||||||
for asset_id in ids:
|
for asset_id in ids:
|
||||||
@@ -722,38 +678,15 @@ async def poll_due_assets_once(db: AsyncSession, *, limit: int) -> int:
|
|||||||
success_count += 1
|
success_count += 1
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
failed_count += 1
|
failed_count += 1
|
||||||
log_operation_error(
|
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_POLL_FAILED.value, source=PrivatePortraitEventSource.CELERY.value, asset_id=asset_id, exc=exc)
|
||||||
domain=DOMAIN,
|
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})
|
||||||
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)
|
return len(ids)
|
||||||
|
|
||||||
|
|
||||||
async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[str, int]:
|
async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[str, int]:
|
||||||
log_operation_event(
|
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})
|
||||||
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]
|
statuses = [PrivatePortraitRemoteDeleteStatus.PENDING.value, PrivatePortraitRemoteDeleteStatus.FAILED.value]
|
||||||
asset_rows = await db.execute(
|
asset_rows = await db.execute(select(PrivatePortraitAsset.id).where(PrivatePortraitAsset.remote_delete_status.in_(statuses)).order_by(PrivatePortraitAsset.updated_at.asc()).limit(limit))
|
||||||
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()]
|
asset_ids = [row[0] for row in asset_rows.all()]
|
||||||
for asset_id in asset_ids:
|
for asset_id in asset_ids:
|
||||||
await delete_asset_remote(db, asset_id=asset_id)
|
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))
|
remaining = max(0, limit - len(asset_ids))
|
||||||
group_count = 0
|
group_count = 0
|
||||||
if remaining > 0:
|
if remaining > 0:
|
||||||
group_rows = await db.execute(
|
group_rows = await db.execute(select(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.remote_delete_status.in_(statuses)).order_by(PrivatePortraitAssetGroup.updated_at.asc()).limit(remaining))
|
||||||
select(PrivatePortraitAssetGroup)
|
|
||||||
.where(PrivatePortraitAssetGroup.remote_delete_status.in_(statuses))
|
|
||||||
.order_by(PrivatePortraitAssetGroup.updated_at.asc())
|
|
||||||
.limit(remaining)
|
|
||||||
)
|
|
||||||
client = ArkPrivateAssetClient(for_celery=True)
|
client = ArkPrivateAssetClient(for_celery=True)
|
||||||
groups = list(group_rows.scalars().all())
|
groups = list(group_rows.scalars().all())
|
||||||
group_count = len(groups)
|
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)
|
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}
|
result = {"asset_count": len(asset_ids), "group_count": group_count, "total_count": len(asset_ids) + group_count}
|
||||||
log_operation_event(
|
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_DONE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, detail=result)
|
||||||
domain=DOMAIN,
|
|
||||||
event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_DONE.value,
|
|
||||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
|
||||||
source=PrivatePortraitEventSource.CELERY.value,
|
|
||||||
detail=result,
|
|
||||||
)
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ from app.enums.private_portrait import (
|
|||||||
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
||||||
PrivatePortraitAssetGroupStatus,
|
PrivatePortraitAssetGroupStatus,
|
||||||
PrivatePortraitAssetStatus,
|
PrivatePortraitAssetStatus,
|
||||||
|
PrivatePortraitAssetType,
|
||||||
PrivatePortraitEventSource,
|
PrivatePortraitEventSource,
|
||||||
PrivatePortraitEventStatus,
|
PrivatePortraitEventStatus,
|
||||||
PrivatePortraitEventType,
|
PrivatePortraitEventType,
|
||||||
|
PrivatePortraitLibraryType,
|
||||||
PrivatePortraitProjectStatus,
|
PrivatePortraitProjectStatus,
|
||||||
PrivatePortraitRemoteDeleteStatus,
|
PrivatePortraitRemoteDeleteStatus,
|
||||||
)
|
)
|
||||||
@@ -27,16 +29,24 @@ DOMAIN = "private_portrait"
|
|||||||
|
|
||||||
def _safe_slug(value: str, *, max_length: int = 80) -> str:
|
def _safe_slug(value: str, *, max_length: int = 80) -> str:
|
||||||
value = (value or "").strip().lower()
|
value = (value or "").strip().lower()
|
||||||
# 先保留常见英文数字连字符;中文等字符统一转 _,仅用于本地项目 slug。
|
|
||||||
value = re.sub(r"[^a-z0-9_-]+", "_", value)
|
value = re.sub(r"[^a-z0-9_-]+", "_", value)
|
||||||
value = re.sub(r"_+", "_", value).strip("_-")
|
value = re.sub(r"_+", "_", value).strip("_-")
|
||||||
return (value[:max_length] or "project")
|
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:
|
def project_to_out(project: PrivatePortraitProject, *, include_user: bool = False) -> PrivatePortraitProjectOut:
|
||||||
return PrivatePortraitProjectOut(
|
return PrivatePortraitProjectOut(
|
||||||
id=project.id,
|
id=project.id,
|
||||||
user_id=project.user_id if include_user else None,
|
user_id=project.user_id if include_user else None,
|
||||||
|
library_type=project.library_type,
|
||||||
name=project.name,
|
name=project.name,
|
||||||
name_slug=project.name_slug,
|
name_slug=project.name_slug,
|
||||||
remote_project_name=project.remote_project_name,
|
remote_project_name=project.remote_project_name,
|
||||||
@@ -44,37 +54,57 @@ def project_to_out(project: PrivatePortraitProject, *, include_user: bool = Fals
|
|||||||
status=project.status,
|
status=project.status,
|
||||||
asset_group_count=project.asset_group_count or 0,
|
asset_group_count=project.asset_group_count or 0,
|
||||||
asset_count=project.asset_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_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,
|
last_used_at=project.last_used_at,
|
||||||
created_at=project.created_at,
|
created_at=project.created_at,
|
||||||
updated_at=project.updated_at,
|
updated_at=project.updated_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_user_project(db: AsyncSession, *, user_id: str, project_id: str) -> PrivatePortraitProject:
|
async def get_user_project(
|
||||||
result = await db.execute(
|
db: AsyncSession,
|
||||||
select(PrivatePortraitProject).where(
|
*,
|
||||||
PrivatePortraitProject.id == project_id,
|
user_id: str,
|
||||||
PrivatePortraitProject.user_id == user_id,
|
project_id: str,
|
||||||
PrivatePortraitProject.deleted_at.is_(None),
|
library_type: str | None = None,
|
||||||
).limit(1)
|
) -> PrivatePortraitProject:
|
||||||
)
|
filters = [
|
||||||
|
PrivatePortraitProject.id == project_id,
|
||||||
|
PrivatePortraitProject.user_id == user_id,
|
||||||
|
PrivatePortraitProject.deleted_at.is_(None),
|
||||||
|
]
|
||||||
|
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()
|
project = result.scalar_one_or_none()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="真人素材项目不存在")
|
raise HTTPException(status_code=404, detail="私域人像素材项目不存在")
|
||||||
return project
|
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)
|
slug = _safe_slug(payload.name)
|
||||||
project = PrivatePortraitProject(
|
project = PrivatePortraitProject(
|
||||||
id=generate_id(),
|
id=generate_id(),
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
library_type=library_type,
|
||||||
name=payload.name.strip(),
|
name=payload.name.strip(),
|
||||||
name_slug=slug,
|
name_slug=slug,
|
||||||
remote_project_name=PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
remote_project_name=remote_project_name,
|
||||||
description=payload.description,
|
description=payload.description,
|
||||||
status=PrivatePortraitProjectStatus.VALIDATING.value,
|
status=status or _status_for_created_project(library_type),
|
||||||
)
|
)
|
||||||
db.add(project)
|
db.add(project)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -85,35 +115,38 @@ async def create_project(db: AsyncSession, *, user_id: str, payload: PrivatePort
|
|||||||
source=PrivatePortraitEventSource.API.value,
|
source=PrivatePortraitEventSource.API.value,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
message="创建待认证真人素材项目",
|
message="创建私域人像素材项目",
|
||||||
detail={"name": project.name, "remote_project_name": project.remote_project_name, "status": project.status},
|
detail={"name": project.name, "library_type": library_type, "remote_project_name": project.remote_project_name, "status": project.status},
|
||||||
)
|
)
|
||||||
return project
|
return project
|
||||||
|
|
||||||
|
|
||||||
async def update_project(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitProjectUpdate) -> PrivatePortraitProject:
|
async def update_project(
|
||||||
project = await get_user_project(db, user_id=user_id, project_id=project_id)
|
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 = {
|
before = {
|
||||||
"name": project.name,
|
"name": project.name,
|
||||||
"name_slug": project.name_slug,
|
"name_slug": project.name_slug,
|
||||||
"remote_project_name": project.remote_project_name,
|
"remote_project_name": project.remote_project_name,
|
||||||
"description": project.description,
|
"description": project.description,
|
||||||
"status": project.status,
|
"status": project.status,
|
||||||
|
"library_type": project.library_type,
|
||||||
}
|
}
|
||||||
if payload.name is not None:
|
if payload.name is not None:
|
||||||
new_name = payload.name.strip()
|
new_name = payload.name.strip()
|
||||||
if new_name and new_name != project.name:
|
if new_name and new_name != project.name:
|
||||||
project.name = new_name
|
project.name = new_name
|
||||||
project.name_slug = _safe_slug(new_name)
|
project.name_slug = _safe_slug(new_name)
|
||||||
project.remote_project_name = PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME
|
|
||||||
if payload.description is not None:
|
if payload.description is not None:
|
||||||
project.description = payload.description
|
project.description = payload.description
|
||||||
if payload.status is not None:
|
if payload.status is not None:
|
||||||
allowed_statuses = {
|
allowed_statuses = {item.value for item in PrivatePortraitProjectStatus if item != PrivatePortraitProjectStatus.DELETED}
|
||||||
PrivatePortraitProjectStatus.VALIDATING.value,
|
|
||||||
PrivatePortraitProjectStatus.ACTIVE.value,
|
|
||||||
PrivatePortraitProjectStatus.VALIDATE_FAILED.value,
|
|
||||||
}
|
|
||||||
if payload.status not in allowed_statuses:
|
if payload.status not in allowed_statuses:
|
||||||
raise HTTPException(status_code=400, detail="项目状态不支持")
|
raise HTTPException(status_code=400, detail="项目状态不支持")
|
||||||
project.status = payload.status
|
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,
|
"remote_project_name": project.remote_project_name,
|
||||||
"description": project.description,
|
"description": project.description,
|
||||||
"status": project.status,
|
"status": project.status,
|
||||||
|
"library_type": project.library_type,
|
||||||
}
|
}
|
||||||
log_operation_event(
|
log_operation_event(
|
||||||
domain=DOMAIN,
|
domain=DOMAIN,
|
||||||
@@ -132,7 +166,7 @@ async def update_project(db: AsyncSession, *, user_id: str, project_id: str, pay
|
|||||||
source=PrivatePortraitEventSource.API.value,
|
source=PrivatePortraitEventSource.API.value,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
message="更新真人素材项目",
|
message="更新私域人像素材项目",
|
||||||
detail={"before": before, "after": after},
|
detail={"before": before, "after": after},
|
||||||
)
|
)
|
||||||
return project
|
return project
|
||||||
@@ -146,18 +180,27 @@ async def list_projects(
|
|||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
keyword: str | None = None,
|
keyword: str | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
|
library_type: str | None = None,
|
||||||
) -> tuple[list[PrivatePortraitProject], int]:
|
) -> tuple[list[PrivatePortraitProject], int]:
|
||||||
page = max(1, page)
|
page = max(1, page)
|
||||||
page_size = min(max(1, page_size), 100)
|
page_size = min(max(1, page_size), 100)
|
||||||
filters = [PrivatePortraitProject.deleted_at.is_(None)]
|
filters = [PrivatePortraitProject.deleted_at.is_(None)]
|
||||||
if user_id:
|
if user_id:
|
||||||
filters.append(PrivatePortraitProject.user_id == user_id)
|
filters.append(PrivatePortraitProject.user_id == user_id)
|
||||||
|
if library_type:
|
||||||
|
filters.append(PrivatePortraitProject.library_type == library_type)
|
||||||
if keyword:
|
if keyword:
|
||||||
filters.append(PrivatePortraitProject.name.ilike(f"%{keyword.strip()}%"))
|
filters.append(PrivatePortraitProject.name.ilike(f"%{keyword.strip()}%"))
|
||||||
if status:
|
if status:
|
||||||
filters.append(PrivatePortraitProject.status == status)
|
filters.append(PrivatePortraitProject.status == status)
|
||||||
total = (await db.execute(select(func.count(PrivatePortraitProject.id)).where(*filters))).scalar_one()
|
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)
|
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(
|
select(
|
||||||
PrivatePortraitAsset.project_id,
|
PrivatePortraitAsset.project_id,
|
||||||
func.count(PrivatePortraitAsset.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, 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))
|
.where(PrivatePortraitAsset.project_id.in_(project_ids), PrivatePortraitAsset.deleted_at.is_(None))
|
||||||
.group_by(PrivatePortraitAsset.project_id)
|
.group_by(PrivatePortraitAsset.project_id)
|
||||||
)
|
)
|
||||||
group_count_map = {pid: int(count or 0) for pid, count in group_rows.all()}
|
group_count_map = {pid: int(count or 0) for pid, count in group_rows.all()}
|
||||||
asset_count_map: dict[str, tuple[int, int]] = {}
|
asset_count_map: dict[str, tuple[int, int, int, int, int, int]] = {}
|
||||||
for pid, total, active_total in asset_rows.all():
|
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(active_total or 0))
|
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:
|
for pid in project_ids:
|
||||||
total, active_total = asset_count_map.get(pid, (0, 0))
|
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, active_asset_count=active_total))
|
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:
|
async def soft_delete_project(
|
||||||
project = await get_user_project(db, user_id=user_id, project_id=project_id)
|
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)
|
now = datetime.now(timezone.utc)
|
||||||
project.deleted_at = now
|
project.deleted_at = now
|
||||||
project.status = PrivatePortraitProjectStatus.DELETED.value
|
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(
|
||||||
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))
|
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()
|
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
|
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 (
|
from app.enums.private_portrait import (
|
||||||
PRIVATE_PORTRAIT_ASSET_URI_PREFIX,
|
PRIVATE_PORTRAIT_ASSET_URI_PREFIX,
|
||||||
PrivatePortraitAssetStatus,
|
PrivatePortraitAssetStatus,
|
||||||
|
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
|
||||||
PrivatePortraitAssetType,
|
PrivatePortraitAssetType,
|
||||||
PrivatePortraitEventSource,
|
PrivatePortraitEventSource,
|
||||||
PrivatePortraitEventStatus,
|
PrivatePortraitEventStatus,
|
||||||
@@ -25,7 +26,6 @@ DOMAIN = "private_portrait"
|
|||||||
_ASSET_TYPE_TO_REFERENCE_TYPE = {
|
_ASSET_TYPE_TO_REFERENCE_TYPE = {
|
||||||
PrivatePortraitAssetType.IMAGE.value: "image",
|
PrivatePortraitAssetType.IMAGE.value: "image",
|
||||||
PrivatePortraitAssetType.VIDEO.value: "video",
|
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 "")
|
asset_id = str(_ref_get(ref, "private_asset_id") or "")
|
||||||
if not asset_id:
|
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)
|
asset = asset_map.get(asset_id)
|
||||||
if not asset:
|
if not asset:
|
||||||
raise HTTPException(status_code=400, detail="真人素材不存在")
|
raise HTTPException(status_code=400, detail="私域人像素材不存在")
|
||||||
if asset.user_id != user_id:
|
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:
|
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:
|
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:
|
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)
|
expected_ref_type = _ASSET_TYPE_TO_REFERENCE_TYPE.get(asset.asset_type)
|
||||||
ref_type = _normalize_ref_type(_ref_get(ref, "type"))
|
ref_type = _normalize_ref_type(_ref_get(ref, "type"))
|
||||||
if expected_ref_type and ref_type and ref_type != expected_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}"
|
provider_url = f"{PRIVATE_PORTRAIT_ASSET_URI_PREFIX}{asset.remote_asset_id}"
|
||||||
_ref_set(ref, "source", PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value)
|
_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
+36
-36
@@ -1,37 +1,37 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<title>民众智创</title>
|
<title>民众智创</title>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var cached = localStorage.getItem('siteInfo');
|
var cached = localStorage.getItem('siteInfo');
|
||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
try {
|
||||||
var info = JSON.parse(cached);
|
var info = JSON.parse(cached);
|
||||||
if (info.siteName) {
|
if (info.siteName) {
|
||||||
document.title = info.siteName;
|
document.title = info.siteName;
|
||||||
}
|
}
|
||||||
if (info.siteLogo) {
|
if (info.siteLogo) {
|
||||||
var link = document.querySelector('link[rel="icon"]');
|
var link = document.querySelector('link[rel="icon"]');
|
||||||
if (link) {
|
if (link) {
|
||||||
link.href = info.siteLogo;
|
link.href = info.siteLogo;
|
||||||
link.type = 'image/png';
|
link.type = 'image/png';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</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">
|
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>␍
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import CreativePlazaPage from './pages/CreativePlazaPage';
|
|||||||
import TeamManagementPage from './pages/TeamManagementPage';
|
import TeamManagementPage from './pages/TeamManagementPage';
|
||||||
import JoinTeamPage from './pages/JoinTeamPage';
|
import JoinTeamPage from './pages/JoinTeamPage';
|
||||||
import PrivatePortraitAuthorizeResult from './pages/PrivatePortraitAuthorizeResult';
|
import PrivatePortraitAuthorizeResult from './pages/PrivatePortraitAuthorizeResult';
|
||||||
|
import PrivatePortraitVirtualMaterialPage from './pages/PrivatePortraitVirtualMaterialPage';
|
||||||
import { useAuthStore } from './store/useAuthStore';
|
import { useAuthStore } from './store/useAuthStore';
|
||||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { user, loading, checkAuth } = useAuthStore();
|
const { user, loading, checkAuth } = useAuthStore();
|
||||||
@@ -124,6 +125,7 @@ const App = () => {
|
|||||||
<Route path="authorization" element={<AuthorizationPage />} />
|
<Route path="authorization" element={<AuthorizationPage />} />
|
||||||
<Route path="authoriza-waiting" element={<AuthorizationWaitingPage />} />
|
<Route path="authoriza-waiting" element={<AuthorizationWaitingPage />} />
|
||||||
<Route path="materials" element={<MaterialListPage />} />
|
<Route path="materials" element={<MaterialListPage />} />
|
||||||
|
<Route path="materials/private-portrait-virtual" element={<PrivatePortraitVirtualMaterialPage />} />
|
||||||
<Route path="consume" element={<ConsumePage />} />
|
<Route path="consume" element={<ConsumePage />} />
|
||||||
<Route path="popular" element={<PopularPage />} />
|
<Route path="popular" element={<PopularPage />} />
|
||||||
<Route path="authacc" element={<AuthAccountPage />} />
|
<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}`);
|
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> {
|
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 });
|
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();
|
const query = new URLSearchParams();
|
||||||
query.set('page', String(params.page || 1));
|
query.set('page', String(params.page || 1));
|
||||||
query.set('page_size', String(params.pageSize || 20));
|
query.set('page_size', String(params.pageSize || 20));
|
||||||
if (params.status) query.set('status', params.status);
|
if (params.status) query.set('status', params.status);
|
||||||
if (params.keyword) query.set('keyword', params.keyword);
|
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()}`);
|
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}`);
|
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();
|
const query = new URLSearchParams();
|
||||||
query.set('page', String(params.page || 1));
|
query.set('page', String(params.page || 1));
|
||||||
query.set('page_size', String(params.pageSize || 20));
|
query.set('page_size', String(params.pageSize || 20));
|
||||||
if (params.projectId) query.set('project_id', params.projectId);
|
if (params.projectId) query.set('project_id', params.projectId);
|
||||||
if (params.keyword) query.set('keyword', params.keyword);
|
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()}`);
|
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 ──────────────────────────────
|
// ── Team Management APIs ──────────────────────────────
|
||||||
export async function getManagedTeam(): Promise<any> {
|
export async function getManagedTeam(): Promise<any> {
|
||||||
return api.get('/team/managed');
|
return api.get('/team/managed');
|
||||||
|
|||||||
@@ -984,7 +984,7 @@ const AppLayout: React.FC = () => {
|
|||||||
placement="left"
|
placement="left"
|
||||||
onClose={() => setMobileMenuOpen(false)}
|
onClose={() => setMobileMenuOpen(false)}
|
||||||
open={mobileMenuOpen}
|
open={mobileMenuOpen}
|
||||||
width={280}
|
size={280}
|
||||||
closable={true}
|
closable={true}
|
||||||
className="mobile-menu-drawer"
|
className="mobile-menu-drawer"
|
||||||
styles={{
|
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 bg3 from '../assets/bg3.png';
|
||||||
import text from '../assets/testb.png';
|
import text from '../assets/testb.png';
|
||||||
|
|
||||||
|
import UploadSelector from '../components/UploadSelector';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -1255,6 +1257,8 @@ const AIChatPage: React.FC = () => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (isAudio) {
|
if (isAudio) {
|
||||||
@@ -1293,7 +1297,6 @@ const AIChatPage: React.FC = () => {
|
|||||||
}];
|
}];
|
||||||
const labels = generateMediaLabels(newList);
|
const labels = generateMediaLabels(newList);
|
||||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||||
message.success(`${isImage ? '图片' : (isAudio ? '音频' : '视频')}上传成功`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('上传失败');
|
message.error('上传失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1303,6 +1306,118 @@ const AIChatPage: React.FC = () => {
|
|||||||
return false;
|
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[]) => {
|
const handlePrivatePortraitAssetsSelected = (assets: PrivatePortraitSelectableAsset[]) => {
|
||||||
if (mediaType !== 'video') {
|
if (mediaType !== 'video') {
|
||||||
@@ -1346,12 +1461,12 @@ const AIChatPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
// const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
// if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
// e.preventDefault();
|
||||||
handleSend();
|
// handleSend();
|
||||||
}
|
// }
|
||||||
};
|
// };
|
||||||
|
|
||||||
// 检测光标前的 @ 符号
|
// 检测光标前的 @ 符号
|
||||||
const checkMention = (textarea: HTMLTextAreaElement, value: string) => {
|
const checkMention = (textarea: HTMLTextAreaElement, value: string) => {
|
||||||
@@ -1511,7 +1626,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
{/* 隐藏的音频播放器 */}
|
{/* 隐藏的音频播放器 */}
|
||||||
<audio
|
<audio
|
||||||
id="audio-player"
|
id="audio-player"
|
||||||
src={playingAudioUrl || ''}
|
src={playingAudioUrl || null}
|
||||||
autoPlay
|
autoPlay
|
||||||
onEnded={() => setPlayingAudioUrl(null)}
|
onEnded={() => setPlayingAudioUrl(null)}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
@@ -2392,63 +2507,75 @@ const AIChatPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{/* 没有上传时的卡片样式 */}
|
{/* 没有上传时的卡片样式 */}
|
||||||
{currentMedia.length === 0 && (
|
{currentMedia.length === 0 && (
|
||||||
<Upload
|
<UploadSelector
|
||||||
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*,audio/*'}
|
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*,audio/*'}
|
||||||
showUploadList={false}
|
onLocalSelect={handleBatchUpload}
|
||||||
beforeUpload={handleUpload}
|
onHistorySelect={(items) => {
|
||||||
>
|
const newMedia = items.map((item: any) => ({
|
||||||
<Tooltip title={mediaType === 'image'
|
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 === '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 === 'video').length}/${maxVideoCount}${maxAudio > 0 ? `,
|
||||||
音频${currentMedia.filter(m => m.type === 'audio').length}/${maxAudio}` : ''}`
|
音频${currentMedia.filter(m => m.type === 'audio').length}/${maxAudio}` : ''}`
|
||||||
}>
|
}
|
||||||
<div
|
>
|
||||||
style={{
|
<div
|
||||||
width: 54,
|
style={{
|
||||||
height: 74,
|
width: 54,
|
||||||
borderRadius: 7,
|
height: 74,
|
||||||
border: '1px solid rgba(231, 234, 240, 0.95)',
|
borderRadius: 7,
|
||||||
display: 'flex',
|
border: '1px solid rgba(231, 234, 240, 0.95)',
|
||||||
alignItems: 'center',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
alignItems: 'center',
|
||||||
cursor: 'pointer',
|
justifyContent: 'center',
|
||||||
transition: 'all 0.25s ease',
|
cursor: 'pointer',
|
||||||
background: '#ffffff',
|
transition: 'all 0.25s ease',
|
||||||
flexDirection: 'column',
|
background: '#ffffff',
|
||||||
gap: 5,
|
flexDirection: 'column',
|
||||||
transform: 'rotate(-7deg)',
|
gap: 5,
|
||||||
boxShadow: '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)',
|
transform: 'rotate(-7deg)',
|
||||||
}}
|
boxShadow: '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)',
|
||||||
onMouseEnter={(e) => {
|
}}
|
||||||
e.currentTarget.style.borderColor = '#D7DDE7';
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.background = 'linear-gradient(180deg, #ffffff 0%, #F7F8FA 100%)';
|
e.currentTarget.style.borderColor = '#D7DDE7';
|
||||||
e.currentTarget.style.transform = 'rotate(0deg) translateY(-2px)';
|
e.currentTarget.style.background = 'linear-gradient(180deg, #ffffff 0%, #F7F8FA 100%)';
|
||||||
e.currentTarget.style.boxShadow = '0 14px 28px rgba(47, 52, 64, 0.15), inset 0 1px 0 rgba(255,255,255,0.98)';
|
e.currentTarget.style.transform = 'rotate(0deg) translateY(-2px)';
|
||||||
}}
|
e.currentTarget.style.boxShadow = '0 14px 28px rgba(47, 52, 64, 0.15), inset 0 1px 0 rgba(255,255,255,0.98)';
|
||||||
onMouseLeave={(e) => {
|
}}
|
||||||
e.currentTarget.style.borderColor = 'rgba(231, 234, 240, 0.95)';
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.background = 'linear-gradient(180deg, #ffffff 0%, #FAFBFC 100%)';
|
e.currentTarget.style.borderColor = 'rgba(231, 234, 240, 0.95)';
|
||||||
e.currentTarget.style.transform = 'rotate(-7deg)';
|
e.currentTarget.style.background = 'linear-gradient(180deg, #ffffff 0%, #FAFBFC 100%)';
|
||||||
e.currentTarget.style.boxShadow = '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)';
|
e.currentTarget.style.transform = 'rotate(-7deg)';
|
||||||
}}
|
e.currentTarget.style.boxShadow = '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)';
|
||||||
>
|
}}
|
||||||
{uploading ? (
|
>
|
||||||
<LoadingOutlined style={{ fontSize: 17, color: '#667085' }} />
|
{uploading ? (
|
||||||
) : (
|
<LoadingOutlined style={{ fontSize: 17, color: '#667085' }} />
|
||||||
<>
|
) : (
|
||||||
<PlusOutlined style={{ fontSize: 18, color: '#667085', lineHeight: 1 }} />
|
<>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 14px)', columnGap: 2, justifyContent: 'center', color: '#344054', fontSize: 12, fontWeight: 700, lineHeight: 1.05, letterSpacing: 0 }}>
|
<PlusOutlined style={{ fontSize: 18, color: '#667085', lineHeight: 1 }} />
|
||||||
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}><span>参</span><span>考</span></span>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 14px)', columnGap: 2, justifyContent: 'center', color: '#344054', fontSize: 12, fontWeight: 700, lineHeight: 1.05, letterSpacing: 0 }}>
|
||||||
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}><span>内</span><span>容</span></span>
|
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}><span>参</span><span>考</span></span>
|
||||||
</div>
|
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}><span>内</span><span>容</span></span>
|
||||||
</>
|
</div>
|
||||||
)}
|
</>
|
||||||
</div>
|
)}
|
||||||
</Tooltip>
|
</div>
|
||||||
</Upload>
|
</UploadSelector>
|
||||||
)}
|
)}
|
||||||
{mediaType === 'video' && currentMedia.length === 0 && (
|
{/* {mediaType === 'video' && currentMedia.length === 0 && (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => setPrivateAssetPickerOpen(true)}
|
onClick={() => setPrivateAssetPickerOpen(true)}
|
||||||
@@ -2456,7 +2583,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
真人素材库
|
真人素材库
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)} */}
|
||||||
|
|
||||||
{/* 层叠附件展示 - 鼠标移入向右排列展开 */}
|
{/* 层叠附件展示 - 鼠标移入向右排列展开 */}
|
||||||
{currentMedia.length > 0 && (
|
{currentMedia.length > 0 && (
|
||||||
@@ -2560,50 +2687,62 @@ const AIChatPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* 右下角圆形+上传按钮 */}
|
{/* 右下角圆形+上传按钮 */}
|
||||||
{currentMedia.length > 0 && (
|
{currentMedia.length > 0 && (
|
||||||
<Upload
|
<UploadSelector
|
||||||
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*,audio/*'}
|
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*,audio/*'}
|
||||||
showUploadList={false}
|
onLocalSelect={handleBatchUpload}
|
||||||
beforeUpload={handleUpload}
|
onHistorySelect={(items) => {
|
||||||
>
|
const newMedia = items.map((item: any) => ({
|
||||||
<Tooltip title={mediaType === 'image'
|
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 === '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 === 'video').length}/${maxVideoCount}${maxAudio > 0 ? `,
|
||||||
音频${currentMedia.filter(m => m.type === 'audio').length}/${maxAudio}` : ''}`
|
音频${currentMedia.filter(m => m.type === 'audio').length}/${maxAudio}` : ''}`
|
||||||
}>
|
}
|
||||||
<div
|
>
|
||||||
style={{
|
<div
|
||||||
position: 'absolute',
|
style={{
|
||||||
right: 0,
|
position: 'absolute',
|
||||||
bottom: 0,
|
right: 0,
|
||||||
width: 28,
|
bottom: 0,
|
||||||
height: 28,
|
width: 28,
|
||||||
borderRadius: 50,
|
height: 28,
|
||||||
background: '#ffffff',
|
borderRadius: 50,
|
||||||
border: '1px solid rgba(231, 234, 240, 0.95)',
|
background: '#ffffff',
|
||||||
display: 'flex',
|
border: '1px solid rgba(231, 234, 240, 0.95)',
|
||||||
alignItems: 'center',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
alignItems: 'center',
|
||||||
cursor: 'pointer',
|
justifyContent: 'center',
|
||||||
transition: 'all 0.2s ease',
|
cursor: 'pointer',
|
||||||
boxShadow: '0 2px 8px rgba(47, 52, 64, 0.08)',
|
transition: 'all 0.2s ease',
|
||||||
zIndex: 100,
|
boxShadow: '0 2px 8px rgba(47, 52, 64, 0.08)',
|
||||||
}}
|
zIndex: 100,
|
||||||
onMouseEnter={(e) => {
|
}}
|
||||||
e.currentTarget.style.borderColor = '#8b5cf6';
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(139, 92, 246, 0.2)';
|
e.currentTarget.style.borderColor = '#8b5cf6';
|
||||||
}}
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(139, 92, 246, 0.2)';
|
||||||
onMouseLeave={(e) => {
|
}}
|
||||||
e.currentTarget.style.borderColor = 'rgba(231, 234, 240, 0.95)';
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(47, 52, 64, 0.08)';
|
e.currentTarget.style.borderColor = 'rgba(231, 234, 240, 0.95)';
|
||||||
}}
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(47, 52, 64, 0.08)';
|
||||||
>
|
}}
|
||||||
<PlusOutlined style={{ fontSize: 14, color: '#8b5cf6', lineHeight: 1 }} />
|
>
|
||||||
</div>
|
<PlusOutlined style={{ fontSize: 14, color: '#8b5cf6', lineHeight: 1 }} />
|
||||||
</Tooltip>
|
</div>
|
||||||
</Upload>
|
</UploadSelector>
|
||||||
)}
|
)}
|
||||||
{mediaType === 'video' && currentMedia.length > 0 && (
|
{/* {mediaType === 'video' && currentMedia.length > 0 && (
|
||||||
<Tooltip title="选择真人素材库">
|
<Tooltip title="选择真人素材库">
|
||||||
<div
|
<div
|
||||||
onClick={(e) => { e.stopPropagation(); setPrivateAssetPickerOpen(true); }}
|
onClick={(e) => { e.stopPropagation(); setPrivateAssetPickerOpen(true); }}
|
||||||
@@ -2630,7 +2769,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
真
|
真
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)} */}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -2665,7 +2804,23 @@ const AIChatPage: React.FC = () => {
|
|||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleInputKeyDown}
|
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}
|
placeholder={composerPlaceholder}
|
||||||
autoSize={{ minRows: 3, maxRows: 6 }}
|
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import {
|
|||||||
getRecordsPage,
|
getRecordsPage,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { formatDate } from "../utils/formatDate";
|
import { formatDate } from "../utils/formatDate";
|
||||||
|
import UploadSelector from "../components/UploadSelector";
|
||||||
import { generateUUID } from "../utils/uuid";
|
import { generateUUID } from "../utils/uuid";
|
||||||
|
|
||||||
// const calcVideoCredits = (duration: number, resolution: Resolution): number => {
|
// const calcVideoCredits = (duration: number, resolution: Resolution): number => {
|
||||||
@@ -260,10 +261,58 @@ const GeneratePage: React.FC = () => {
|
|||||||
const [creditRatios, setCreditRatios] = useState<any>([]);
|
const [creditRatios, setCreditRatios] = useState<any>([]);
|
||||||
const [cimage, setCimage] = 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(() => {
|
useEffect(() => {
|
||||||
@@ -1772,97 +1821,73 @@ const GeneratePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Upload
|
<UploadSelector
|
||||||
accept="image/*,video/*"
|
accept="image/*,video/*"
|
||||||
showUploadList={false}
|
onLocalSelect={(files) => {
|
||||||
multiple
|
files.forEach(async (file) => {
|
||||||
beforeUpload={(file) => {
|
await handlePasteUpload(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;
|
|
||||||
}}
|
}}
|
||||||
|
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
|
||||||
<div
|
style={{
|
||||||
style={{
|
width: 48,
|
||||||
width: 48,
|
height: 48,
|
||||||
height: 48,
|
borderRadius: 12,
|
||||||
borderRadius: 12,
|
border: "1.5px dashed #d9d9d9",
|
||||||
border: "1.5px dashed #d9d9d9",
|
display: "flex",
|
||||||
display: "flex",
|
alignItems: "center",
|
||||||
alignItems: "center",
|
justifyContent: "center",
|
||||||
justifyContent: "center",
|
cursor: "pointer",
|
||||||
cursor: "pointer",
|
transition: "all 0.2s",
|
||||||
transition: "all 0.2s",
|
flexShrink: 0,
|
||||||
flexShrink: 0,
|
}}
|
||||||
}}
|
onMouseEnter={(e) => {
|
||||||
onMouseEnter={(e) => {
|
e.currentTarget.style.borderColor = "#6366f1";
|
||||||
e.currentTarget.style.borderColor = "#6366f1";
|
e.currentTarget.style.background =
|
||||||
e.currentTarget.style.background =
|
"rgba(99,102,241,0.04)";
|
||||||
"rgba(99,102,241,0.04)";
|
}}
|
||||||
}}
|
onMouseLeave={(e) => {
|
||||||
onMouseLeave={(e) => {
|
e.currentTarget.style.borderColor = "#d9d9d9";
|
||||||
e.currentTarget.style.borderColor = "#d9d9d9";
|
e.currentTarget.style.background = "transparent";
|
||||||
e.currentTarget.style.background = "transparent";
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{uploading ? (
|
||||||
{uploading ? (
|
<LoadingOutlined
|
||||||
<LoadingOutlined
|
style={{ fontSize: 18, color: "#6366f1" }}
|
||||||
style={{ fontSize: 18, color: "#6366f1" }}
|
/>
|
||||||
/>
|
) : (
|
||||||
) : (
|
<PlusOutlined
|
||||||
<PlusOutlined
|
style={{ fontSize: 18, color: "#94a3b8" }}
|
||||||
style={{ fontSize: 18, color: "#94a3b8" }}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</UploadSelector>
|
||||||
</Tooltip>
|
|
||||||
</Upload>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Textarea */}
|
{/* Textarea */}
|
||||||
@@ -1885,6 +1910,23 @@ const GeneratePage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
setShowMention(false);
|
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}
|
rows={3}
|
||||||
placeholder="上传参考素材、输入文字,自由组合图、文多元素。输入 @ 可引用参考内容..."
|
placeholder="上传参考素材、输入文字,自由组合图、文多元素。输入 @ 可引用参考内容..."
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
@@ -1897,7 +1939,7 @@ const GeneratePage: React.FC = () => {
|
|||||||
resize: "none",
|
resize: "none",
|
||||||
caretColor: "#6366f1",
|
caretColor: "#6366f1",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* @ mention dropdown */}
|
{/* @ mention dropdown */}
|
||||||
@@ -4547,7 +4589,7 @@ const GeneratePage: React.FC = () => {
|
|||||||
footer={null}
|
footer={null}
|
||||||
width={480}
|
width={480}
|
||||||
centered
|
centered
|
||||||
destroyOnClose
|
destroyOnHidden
|
||||||
closable={false}
|
closable={false}
|
||||||
title={null}
|
title={null}
|
||||||
styles={{
|
styles={{
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ import {
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getmedit ,getHomeCaseHeader,getHomeCaseButton} from '../api';
|
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(与图片一致)
|
// 把 ISO 时间格式化成 MM-DD HH:mm(与图片一致)
|
||||||
@@ -45,6 +43,7 @@ const HomePage: React.FC = () => {
|
|||||||
const [caseAssets, setCaseAssets] = useState<any[]>([]);
|
const [caseAssets, setCaseAssets] = useState<any[]>([]);
|
||||||
const [previewAsset, setPreviewAsset] = useState<any>(null);
|
const [previewAsset, setPreviewAsset] = useState<any>(null);
|
||||||
const previewVideoRef = useRef<HTMLVideoElement>(null);
|
const previewVideoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [activeContentTab, setActiveContentTab] = useState<'works' | 'cases'>('works');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getHomeCaseHeader().then((res: any) => {
|
getHomeCaseHeader().then((res: any) => {
|
||||||
@@ -96,22 +95,29 @@ const HomePage: React.FC = () => {
|
|||||||
|
|
||||||
|
|
||||||
const aiEntries = [
|
const aiEntries = [
|
||||||
|
{
|
||||||
|
icon: <FileTextOutlined style={{ fontSize: 24 }} />,
|
||||||
|
title: '项目创建',
|
||||||
|
description: '新建项目、设置图文视频参数、核对信息并生成素材',
|
||||||
|
action: '立即创作',
|
||||||
|
path: '/projects',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: <FileTextOutlined style={{ fontSize: 24, color: '#6366f1' }} />,
|
icon: <FileTextOutlined style={{ fontSize: 24 }} />,
|
||||||
title: '爆款复刻',
|
title: '爆款复刻',
|
||||||
description: '上传参考视频与产品图片,一键复刻爆款视频开头',
|
description: '上传参考视频与产品图片,一键复刻爆款视频开头',
|
||||||
action: '立即创作',
|
action: '立即创作',
|
||||||
path: '/initial',
|
path: '/initial',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <ScissorOutlined style={{ fontSize: 24, color: '#f97316' }} />,
|
icon: <ScissorOutlined style={{ fontSize: 24 }} />,
|
||||||
title: '拆镜复刻',
|
title: '拆镜复刻',
|
||||||
description: '精细化镜头复刻工具,拆分参考视频单镜头独立复刻,提升素材原创度,规避素材同质化',
|
description: '精细化镜头复刻工具,拆分参考视频单镜头独立复刻,提升素材原创度,规避素材同质化',
|
||||||
action: '开始混剪',
|
action: '开始拆镜',
|
||||||
path: '/removelens',
|
path: '/removelens',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <RobotOutlined style={{ fontSize: 24, color: '#10b981' }} />,
|
icon: <RobotOutlined style={{ fontSize: 24 }} />,
|
||||||
title: 'AI成片',
|
title: 'AI成片',
|
||||||
description: '输入想法、剧本或上传参考,智能生成视频/图片',
|
description: '输入想法、剧本或上传参考,智能生成视频/图片',
|
||||||
action: '立即生成',
|
action: '立即生成',
|
||||||
@@ -132,13 +138,6 @@ const HomePage: React.FC = () => {
|
|||||||
? mockVideos
|
? mockVideos
|
||||||
: mockVideos.filter(v => v.type === activeTab);
|
: 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 (
|
return (
|
||||||
<div className="content_box">
|
<div className="content_box">
|
||||||
@@ -172,7 +171,7 @@ const HomePage: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* ========== 顶部工作台引导区域(三步流程) ========== */}
|
{/* ========== 顶部工作台引导区域(三步流程) ========== */}
|
||||||
<div className="animate-fadeInUp" style={{
|
{/* <div className="animate-fadeInUp" style={{
|
||||||
padding: '24px 28px',
|
padding: '24px 28px',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
background: 'linear-gradient(135deg, #f0f9ff 0%, #faf5ff 50%, #fef3c7 100%)',
|
background: 'linear-gradient(135deg, #f0f9ff 0%, #faf5ff 50%, #fef3c7 100%)',
|
||||||
@@ -181,7 +180,6 @@ const HomePage: React.FC = () => {
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
{/* 区域标题 */}
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18 }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
@@ -194,19 +192,6 @@ const HomePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
@@ -227,7 +212,6 @@ const HomePage: React.FC = () => {
|
|||||||
<div style={{ textAlign: 'center', fontSize: 20, fontWeight: 700, color: '#1e293b', marginBottom: 14, letterSpacing: 1 }}>
|
<div style={{ textAlign: 'center', fontSize: 20, fontWeight: 700, color: '#1e293b', marginBottom: 14, letterSpacing: 1 }}>
|
||||||
第1步
|
第1步
|
||||||
</div>
|
</div>
|
||||||
{/* 插图占位:项目卡(标题/描述输入框 + 行业分类 chip) */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 140,
|
minHeight: 140,
|
||||||
@@ -240,21 +224,18 @@ const HomePage: React.FC = () => {
|
|||||||
gap: 8,
|
gap: 8,
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
}}>
|
}}>
|
||||||
{/* 项目名称占位 */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<div style={{ width: 8, height: 8, borderRadius: 2, background: '#6366f1' }} />
|
<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 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>
|
||||||
</div>
|
</div>
|
||||||
{/* 行业分类 chip */}
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
<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: '#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: '#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: '#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 style={{ padding: '2px 8px', background: '#f5f3ff', color: '#8b5cf6', borderRadius: 10, fontSize: 10, fontWeight: 500, border: '1px solid #ddd6fe' }}>服饰</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 描述占位行 */}
|
|
||||||
<div style={{ height: 16, background: '#fff', border: '1px solid #e2e8f0', borderRadius: 4 }} />
|
<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 style={{ height: 16, width: '70%', background: '#fff', border: '1px solid #e2e8f0', borderRadius: 4 }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -287,7 +268,6 @@ const HomePage: React.FC = () => {
|
|||||||
<div style={{ textAlign: 'center', fontSize: 20, fontWeight: 700, color: '#1e293b', marginBottom: 14, letterSpacing: 1 }}>
|
<div style={{ textAlign: 'center', fontSize: 20, fontWeight: 700, color: '#1e293b', marginBottom: 14, letterSpacing: 1 }}>
|
||||||
第2步
|
第2步
|
||||||
</div>
|
</div>
|
||||||
{/* 插图占位:图片/视频切换 + 尺寸/时长参数 */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 140,
|
minHeight: 140,
|
||||||
@@ -300,7 +280,6 @@ const HomePage: React.FC = () => {
|
|||||||
gap: 8,
|
gap: 8,
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
}}>
|
}}>
|
||||||
{/* 图片 / 视频 切换 */}
|
|
||||||
<div style={{ display: 'flex', background: '#f1f5f9', borderRadius: 6, padding: 2, gap: 2 }}>
|
<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)' }}>
|
<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 }} />视频
|
<VideoCameraOutlined style={{ fontSize: 11 }} />视频
|
||||||
@@ -309,7 +288,6 @@ const HomePage: React.FC = () => {
|
|||||||
<PictureOutlined style={{ fontSize: 11 }} /> 图片
|
<PictureOutlined style={{ fontSize: 11 }} /> 图片
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 尺寸参数 */}
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 9, color: '#94a3b8', marginBottom: 3 }}>尺寸比例</div>
|
<div style={{ fontSize: 9, color: '#94a3b8', marginBottom: 3 }}>尺寸比例</div>
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<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 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>
|
||||||
{/* 时长参数 */}
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 9, color: '#94a3b8', marginBottom: 3 }}>时长</div>
|
<div style={{ fontSize: 9, color: '#94a3b8', marginBottom: 3 }}>时长</div>
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<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 }}>
|
<div style={{ textAlign: 'center', fontSize: 20, fontWeight: 700, color: '#1e293b', marginBottom: 14, letterSpacing: 1 }}>
|
||||||
第3步
|
第3步
|
||||||
</div>
|
</div>
|
||||||
{/* 插图占位:核对清单(✓ 项)+ 一键生成按钮 */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 140,
|
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={{ 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 style={{ fontSize: 10, color: '#065f46', fontWeight: 500 }}>尺寸 9:16 · 时长 5s</div>
|
||||||
</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>
|
||||||
{/* 一键生成按钮 */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -450,7 +421,6 @@ const HomePage: React.FC = () => {
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 高光层(hover 时轻微变亮) */}
|
|
||||||
<span style={{
|
<span style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -466,11 +436,11 @@ const HomePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div> */}
|
||||||
|
|
||||||
{/* ========== AI 创作入口区域 ========== */}
|
{/* ========== AI 创作入口区域 ========== */}
|
||||||
<div className="animate-fadeInUp stagger-children" style={{
|
<div className="animate-fadeInUp" style={{
|
||||||
padding: '24px 28px',
|
padding: '20px',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
border: '1px solid #e2e8f0',
|
border: '1px solid #e2e8f0',
|
||||||
@@ -480,7 +450,7 @@ const HomePage: React.FC = () => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
marginBottom: 18,
|
marginBottom: 16,
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 4, height: 18, borderRadius: 2,
|
width: 4, height: 18, borderRadius: 2,
|
||||||
@@ -494,13 +464,13 @@ const HomePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 16 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
|
||||||
{aiEntries.map((entry, index) => {
|
{aiEntries.map((entry, index) => {
|
||||||
// 三个入口用三种不同色调的渐变光晕作为视觉区分,但都保持白底卡片
|
|
||||||
const accentMap = [
|
const accentMap = [
|
||||||
{ color: '#6366f1', light: 'rgba(99,102,241,0.10)', tag: '复刻', bg: hot },
|
{ color: '#3b82f6', light: 'rgba(59,130,246,0.12)', tag: '项目' },
|
||||||
{ color: '#f97316', light: 'rgba(249,115,22,0.10)', tag: '混剪', bg: mashup },
|
{ color: '#6366f1', light: 'rgba(99,102,241,0.12)', tag: '复刻' },
|
||||||
{ color: '#10b981', light: 'rgba(16,185,129,0.10)', tag: '云创', bg: aicreate },
|
{ 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];
|
const accent = accentMap[index] || accentMap[0];
|
||||||
return (
|
return (
|
||||||
@@ -509,57 +479,70 @@ const HomePage: React.FC = () => {
|
|||||||
onClick={() => navigate(entry.path)}
|
onClick={() => navigate(entry.path)}
|
||||||
className="project-card"
|
className="project-card"
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: '1',
|
||||||
padding: '20px 22px',
|
minWidth: 260,
|
||||||
borderRadius: 14,
|
height: 120,
|
||||||
|
padding: '16px',
|
||||||
|
borderRadius: 16,
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
border: '1px solid #e2e8f0',
|
border: '1px solid #e2e8f0',
|
||||||
cursor: 'pointer',
|
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',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
display: 'flex',
|
||||||
backgroundImage: `url(${accent.bg})`,
|
alignItems: 'center',
|
||||||
backgroundRepeat: 'no-repeat',
|
gap: 14,
|
||||||
backgroundSize: '100% 100%',
|
|
||||||
backgroundPosition: 'center',
|
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.borderColor = accent.color;
|
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) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.borderColor = '#e2e8f0';
|
e.currentTarget.style.borderColor = '#e2e8f0';
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
e.currentTarget.style.boxShadow = 'none';
|
||||||
|
e.currentTarget.style.background = '#fff';
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 顶部装饰光带 */}
|
<div
|
||||||
<div style={{
|
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={{
|
|
||||||
width: 48, height: 48,
|
width: 48, height: 48,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
background: accent.light,
|
background: accent.light,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
}}>
|
flexShrink: 0,
|
||||||
<span style={{ color: accent.color, fontSize: 22, display: 'flex' }}>{entry.icon}</span>
|
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={{ 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: '#64748b', }}>
|
||||||
|
{entry.description}
|
||||||
</div>
|
</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 }}>
|
|
||||||
{entry.title}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: '#6b7280', marginBottom: 14, lineHeight: 1.5, minHeight: 36 }}>
|
|
||||||
{entry.description}
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -568,6 +551,7 @@ const HomePage: React.FC = () => {
|
|||||||
color: accent.color,
|
color: accent.color,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
{entry.action}
|
{entry.action}
|
||||||
<ArrowRightOutlined style={{ fontSize: 12 }} />
|
<ArrowRightOutlined style={{ fontSize: 12 }} />
|
||||||
@@ -578,331 +562,299 @@ const HomePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ========== 近期作品区域 ========== */}
|
{/* ========== 作品与案例区域 ========== */}
|
||||||
<div className="animate-fadeInUp" style={{
|
<div className="animate-fadeInUp" style={{
|
||||||
padding: '24px 28px',
|
padding: '24px 28px',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
border: '1px solid #e2e8f0',
|
border: '1px solid #e2e8f0',
|
||||||
marginBottom: 20,
|
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<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={{
|
<div style={{
|
||||||
width: 4, height: 18, borderRadius: 2,
|
width: 4, height: 18, borderRadius: 2,
|
||||||
background: 'linear-gradient(180deg, #6366f1, #a855f7)',
|
background: 'linear-gradient(180deg, #6366f1, #a855f7)',
|
||||||
}} />
|
}} />
|
||||||
<div style={{ fontSize: 17, fontWeight: 700, color: '#1f2937', letterSpacing: 0.3 }}>
|
<div style={{ fontSize: 17, fontWeight: 700, color: '#1f2937', letterSpacing: 0.3 }}>
|
||||||
近期作品
|
{activeContentTab === 'works' ? '近期作品' : '素材案例'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
{/* Tab切换 */}
|
|
||||||
<div style={{ marginBottom: 20 }}>
|
|
||||||
<Tabs
|
|
||||||
activeKey={activeTab}
|
|
||||||
onChange={handleTabChange}
|
|
||||||
items={tabs.map(tab => ({
|
|
||||||
key: tab.key,
|
|
||||||
label: tab.label,
|
|
||||||
}))}
|
|
||||||
className="homepage-tabs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 视频网格 */}
|
|
||||||
<div className="stagger-children" style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 16 }}>
|
|
||||||
{filteredVideos.length === 0 ? (
|
|
||||||
<div style={{
|
|
||||||
gridColumn: '1 / -1',
|
|
||||||
padding: '60px 0',
|
|
||||||
textAlign: 'center',
|
|
||||||
color: '#94a3b8',
|
|
||||||
fontSize: 14,
|
|
||||||
}}>
|
|
||||||
<PictureOutlined style={{ fontSize: 36, color: '#cbd5e1', marginBottom: 8 }} />
|
|
||||||
<div>暂无作品</div>
|
|
||||||
</div>
|
|
||||||
) : filteredVideos.map((video) => (
|
|
||||||
<div
|
|
||||||
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`);
|
|
||||||
} else if (video.type === 'shotReplicate' && id != null) {
|
|
||||||
navigate(`/removelens/${id}/removefenbu`);
|
|
||||||
} else if (video.type === 'chatAi') {
|
|
||||||
navigate(`/conversation`);
|
|
||||||
} else if (video.type === 'project') {
|
|
||||||
navigate(`/project`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
borderRadius: 12,
|
|
||||||
overflow: 'hidden',
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: '#fff',
|
|
||||||
border: '1px solid #e2e8f0',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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' || '';
|
|
||||||
} else if (video.type === 'hot') {
|
|
||||||
rawPath = video.coverUrl || video.resultUrl || video.resultUrl || '';
|
|
||||||
} 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}
|
|
||||||
alt={video.title || video.name || '作品'}
|
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
{(() => {
|
|
||||||
const isImage = String(video.resourceType ?? '').toLowerCase() === 'image';
|
|
||||||
if (isImage) return null;
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
background: 'rgba(0,0,0,0.2)',
|
|
||||||
}}>
|
|
||||||
<VideoCameraOutlined style={{ fontSize: 28, color: '#fff' }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
padding: '10px 12px',
|
|
||||||
background: '#f8fafc',
|
|
||||||
paddingTop: 0,
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
marginTop: 6,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#64748b',
|
|
||||||
}}>
|
|
||||||
{(() => {
|
|
||||||
// 显示所属模块,而非媒体类型
|
|
||||||
const moduleMap: Record<string, string> = {
|
|
||||||
project: '项目媒体',
|
|
||||||
chatAi: 'AI成片',
|
|
||||||
hotOpeningReplicate: '爆款复刻',
|
|
||||||
shotReplicate: '拆镜复刻',
|
|
||||||
};
|
|
||||||
const moduleLabel = moduleMap[video.type] || '其他';
|
|
||||||
return (
|
|
||||||
<span style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
padding: '1px 6px',
|
|
||||||
border: '1px solid #3b82f6',
|
|
||||||
borderRadius: 4,
|
|
||||||
color: '#3b82f6',
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 500,
|
|
||||||
background: '#fff',
|
|
||||||
lineHeight: 1.4,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}>
|
|
||||||
{moduleLabel}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
<span style={{ color: '#94a3b8' }}>·</span>
|
|
||||||
<span style={{ whiteSpace: 'nowrap' }}>{formatShortDate(video.generatedTime)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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切换:近期作品 / 素材案例 */}
|
||||||
|
<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切换 */}
|
{/* 内容区域 */}
|
||||||
<div style={{ marginBottom: 20 }}>
|
{activeContentTab === 'works' ? (
|
||||||
<Tabs
|
<>
|
||||||
activeKey={activeCaseTab}
|
{/* 近期作品子Tab */}
|
||||||
onChange={(key) => {
|
<div style={{ marginBottom: 20 }}>
|
||||||
setActiveCaseTab(key);
|
<Tabs
|
||||||
getHomeCaseButton(key).then((btnRes: any) => {
|
activeKey={activeTab}
|
||||||
if (btnRes?.categories?.[0]?.assets) {
|
onChange={handleTabChange}
|
||||||
setCaseAssets(btnRes.categories[0].assets);
|
items={tabs.map(tab => ({
|
||||||
} else {
|
key: tab.key,
|
||||||
setCaseAssets([]);
|
label: tab.label,
|
||||||
}
|
}))}
|
||||||
});
|
className="homepage-tabs"
|
||||||
}}
|
/>
|
||||||
items={caseHeader.map((item: any) => ({
|
|
||||||
key: item.id,
|
|
||||||
label: item.name,
|
|
||||||
}))}
|
|
||||||
className="homepage-tabs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ========== 素材案例列表(与近期作品一致) ========== */}
|
|
||||||
<div className="stagger-children" style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 16 }}>
|
|
||||||
{caseAssets.length === 0 ? (
|
|
||||||
<div style={{
|
|
||||||
gridColumn: '1 / -1',
|
|
||||||
padding: '60px 0',
|
|
||||||
textAlign: 'center',
|
|
||||||
color: '#94a3b8',
|
|
||||||
fontSize: 14,
|
|
||||||
}}>
|
|
||||||
<PictureOutlined style={{ fontSize: 36, color: '#cbd5e1', marginBottom: 8 }} />
|
|
||||||
<div>暂无素材</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : caseAssets.map((asset: any, index: number) => (
|
|
||||||
<div
|
{/* 近期作品网格 */}
|
||||||
key={asset.id || index}
|
<div className="stagger-children" style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 16 }}>
|
||||||
className="project-card"
|
{filteredVideos.length === 0 ? (
|
||||||
onClick={() => setPreviewAsset(asset)}
|
<div style={{
|
||||||
style={{
|
gridColumn: '1 / -1',
|
||||||
borderRadius: 12,
|
padding: '60px 0',
|
||||||
overflow: 'hidden',
|
textAlign: 'center',
|
||||||
cursor: 'pointer',
|
color: '#94a3b8',
|
||||||
background: '#fff',
|
fontSize: 14,
|
||||||
border: '1px solid #e2e8f0',
|
}}>
|
||||||
}}
|
<PictureOutlined style={{ fontSize: 36, color: '#cbd5e1', marginBottom: 8 }} />
|
||||||
>
|
<div>暂无作品</div>
|
||||||
{/* 媒体区域 16:9 */}
|
</div>
|
||||||
<div style={{ position: 'relative', aspectRatio: '16/9', }}>
|
) : filteredVideos.map((video) => (
|
||||||
{asset.mediaType === 'video' ? (
|
<div
|
||||||
<>
|
key={video.id || `${video.type}-${Math.random()}`}
|
||||||
<video
|
className="project-card"
|
||||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${asset.url}`}
|
onClick={() => {
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
const id = video.moduleProjectId;
|
||||||
muted
|
if (video.type === 'hotOpeningReplicate' && id != null) {
|
||||||
playsInline
|
navigate(`/initial/${id}/initialinfo`);
|
||||||
/>
|
} else if (video.type === 'shotReplicate' && id != null) {
|
||||||
|
navigate(`/removelens/${id}/removefenbu`);
|
||||||
|
} else if (video.type === 'chatAi') {
|
||||||
|
navigate(`/conversation`);
|
||||||
|
} else if (video.type === 'project') {
|
||||||
|
navigate(`/project`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
position: 'relative',
|
||||||
|
aspectRatio: '16/9',
|
||||||
|
}}>
|
||||||
|
{(() => {
|
||||||
|
const apiBase = (import.meta.env.VITE_API_BASE as string) || 'http://localhost:8000';
|
||||||
|
const isImage = video.type !== 'hotOpeningReplicate'
|
||||||
|
&& (
|
||||||
|
String(video.resourceType ?? '').toLowerCase() === 'image'
|
||||||
|
|| video.resourceType === 1
|
||||||
|
|| String(video.resourceType ?? '') === '1'
|
||||||
|
);
|
||||||
|
let rawPath = '';
|
||||||
|
if (isImage) {
|
||||||
|
rawPath = '/static' + video.resultUrl + '&w=300&p=50' || '';
|
||||||
|
} else if (video.type === 'hot') {
|
||||||
|
rawPath = video.coverUrl || video.resultUrl || video.resultUrl || '';
|
||||||
|
} else {
|
||||||
|
rawPath = video.coverUrl || video.resultUrl || video.resultUrl || '';
|
||||||
|
}
|
||||||
|
const src = rawPath
|
||||||
|
? (rawPath.startsWith('http') ? rawPath : apiBase + rawPath)
|
||||||
|
: '';
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={video.title || video.name || '作品'}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{(() => {
|
||||||
|
const isImage = String(video.resourceType ?? '').toLowerCase() === 'image';
|
||||||
|
if (isImage) return null;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'rgba(0,0,0,0.2)',
|
||||||
|
}}>
|
||||||
|
<VideoCameraOutlined style={{ fontSize: 28, color: '#fff' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
background: '#f8fafc',
|
||||||
|
paddingTop: 0,
|
||||||
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute',
|
marginTop: 6,
|
||||||
inset: 0,
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
gap: 6,
|
||||||
background: 'rgba(0,0,0,0.2)',
|
fontSize: 12,
|
||||||
|
color: '#64748b',
|
||||||
}}>
|
}}>
|
||||||
<VideoCameraOutlined style={{ fontSize: 28, color: '#fff' }} />
|
{(() => {
|
||||||
|
const moduleMap: Record<string, string> = {
|
||||||
|
project: '项目媒体',
|
||||||
|
chatAi: 'AI成片',
|
||||||
|
hotOpeningReplicate: '爆款复刻',
|
||||||
|
shotReplicate: '拆镜复刻',
|
||||||
|
};
|
||||||
|
const moduleLabel = moduleMap[video.type] || '其他';
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '1px 6px',
|
||||||
|
border: '1px solid #3b82f6',
|
||||||
|
borderRadius: 4,
|
||||||
|
color: '#3b82f6',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
background: '#fff',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{moduleLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<span style={{ color: '#94a3b8' }}>·</span>
|
||||||
|
<span style={{ whiteSpace: 'nowrap' }}>{formatShortDate(video.generatedTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${asset.url}`}
|
|
||||||
alt={asset.title || `素材 ${index + 1}`}
|
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* 底部信息栏 */}
|
|
||||||
<div style={{
|
|
||||||
padding: '10px 12px',
|
|
||||||
background: '#f8fafc',
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#64748b',
|
|
||||||
textAlign: 'center',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}>
|
|
||||||
{asset.title || `素材 ${index + 1}`}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</>
|
||||||
</div>
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 素材案例子Tab */}
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeCaseTab}
|
||||||
|
onChange={(key) => {
|
||||||
|
setActiveCaseTab(key);
|
||||||
|
getHomeCaseButton(key).then((btnRes: any) => {
|
||||||
|
if (btnRes?.categories?.[0]?.assets) {
|
||||||
|
setCaseAssets(btnRes.categories[0].assets);
|
||||||
|
} else {
|
||||||
|
setCaseAssets([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
items={caseHeader.map((item: any) => ({
|
||||||
|
key: item.id,
|
||||||
|
label: item.name,
|
||||||
|
}))}
|
||||||
|
className="homepage-tabs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 素材案例网格 */}
|
||||||
|
<div className="stagger-children" style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 16 }}>
|
||||||
|
{caseAssets.length === 0 ? (
|
||||||
|
<div style={{
|
||||||
|
gridColumn: '1 / -1',
|
||||||
|
padding: '60px 0',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: 14,
|
||||||
|
}}>
|
||||||
|
<PictureOutlined style={{ fontSize: 36, color: '#cbd5e1', marginBottom: 8 }} />
|
||||||
|
<div>暂无素材</div>
|
||||||
|
</div>
|
||||||
|
) : caseAssets.map((asset: any, index: number) => (
|
||||||
|
<div
|
||||||
|
key={asset.id || index}
|
||||||
|
className="project-card"
|
||||||
|
onClick={() => setPreviewAsset(asset)}
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ position: 'relative', aspectRatio: '16/9', }}>
|
||||||
|
{asset.mediaType === 'video' ? (
|
||||||
|
<>
|
||||||
|
<video
|
||||||
|
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${asset.url}`}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'rgba(0,0,0,0.2)',
|
||||||
|
}}>
|
||||||
|
<VideoCameraOutlined style={{ fontSize: 28, color: '#fff' }} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${asset.url}`}
|
||||||
|
alt={asset.title || `素材 ${index + 1}`}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
background: '#f8fafc',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#64748b',
|
||||||
|
textAlign: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{asset.title || `素材 ${index + 1}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ========== 预览弹窗 ========== */}
|
{/* ========== 预览弹窗 ========== */}
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -449,7 +449,10 @@ const GenerateConver: React.FC = () => {
|
|||||||
border: '1px solid rgba(99, 102, 241, 0.08)',
|
border: '1px solid rgba(99, 102, 241, 0.08)',
|
||||||
position: 'relative', overflow: 'hidden', flexWrap: 'wrap', gap: 12,
|
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 style={{ width: 32, height: 2, background: 'linear-gradient(90deg, transparent, #6366f1, #8b5cf6, transparent)', borderRadius: 1 }} /> */}
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{
|
<h2 style={{
|
||||||
|
|||||||
@@ -90,9 +90,6 @@ const JoinTeamPage: React.FC = () => {
|
|||||||
extra={
|
extra={
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" onClick={() => navigate('/projects')}>返回首页</Button>
|
<Button type="primary" onClick={() => navigate('/projects')}>返回首页</Button>
|
||||||
{user && (
|
|
||||||
<Button onClick={() => navigate('/team-management')}>团队管理</Button>
|
|
||||||
)}
|
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -140,7 +137,6 @@ const JoinTeamPage: React.FC = () => {
|
|||||||
extra={
|
extra={
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" onClick={() => navigate('/projects')}>返回首页</Button>
|
<Button type="primary" onClick={() => navigate('/projects')}>返回首页</Button>
|
||||||
<Button onClick={() => navigate('/team-management')}>团队管理</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -295,9 +295,8 @@
|
|||||||
|
|
||||||
.login-code-btn {
|
.login-code-btn {
|
||||||
height: 48px !important;
|
height: 48px !important;
|
||||||
border-radius: 0 10px 10px 0 !important;
|
border-radius: 10 !important;
|
||||||
border: 1.5px solid #e2e8f0 !important;
|
border: 1.5px solid #e2e8f0 !important;
|
||||||
border-left: none !important;
|
|
||||||
font-weight: 600 !important;
|
font-weight: 600 !important;
|
||||||
min-width: 100px !important;
|
min-width: 100px !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Button, Table, Tag, Input, Pagination, Typography, Select, App, Modal } from 'antd';
|
import { Button, Table, Tag, Input, Pagination, Typography, Select, App, Modal } from 'antd';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { getResourcesMaterialList, getPreTestList, submitPreTest, getDefaultPreTest } from '../api';
|
||||||
import PreResultDisplay from '../components/PreResultDisplay';
|
import PreResultDisplay from '../components/PreResultDisplay';
|
||||||
|
|
||||||
@@ -436,6 +436,19 @@ const MaterialListPage: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
<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
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
loading={pushTemplatesLoading}
|
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
|
<video
|
||||||
controls
|
controls
|
||||||
src={videoUrl}
|
src={videoUrl}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%'}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -257,14 +257,23 @@ export interface AdminNotification {
|
|||||||
|
|
||||||
export interface PrivatePortraitConfig {
|
export interface PrivatePortraitConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
imageLimit: number;
|
assetLimit: number;
|
||||||
usedImageCount: number;
|
usedAssetCount: number;
|
||||||
remainingImageCount: 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 {
|
export interface PrivatePortraitProject {
|
||||||
id: string;
|
id: string;
|
||||||
userId?: string | null;
|
userId?: string | null;
|
||||||
|
libraryType?: PrivatePortraitLibraryType | string;
|
||||||
name: string;
|
name: string;
|
||||||
nameSlug?: string | null;
|
nameSlug?: string | null;
|
||||||
remoteProjectName?: string | null;
|
remoteProjectName?: string | null;
|
||||||
@@ -272,7 +281,11 @@ export interface PrivatePortraitProject {
|
|||||||
status: string;
|
status: string;
|
||||||
assetGroupCount: number;
|
assetGroupCount: number;
|
||||||
assetCount: number;
|
assetCount: number;
|
||||||
|
imageAssetCount?: number;
|
||||||
|
videoAssetCount?: number;
|
||||||
activeAssetCount: number;
|
activeAssetCount: number;
|
||||||
|
activeImageAssetCount?: number;
|
||||||
|
activeVideoAssetCount?: number;
|
||||||
lastUsedAt?: string | null;
|
lastUsedAt?: string | null;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
@@ -301,7 +314,6 @@ export interface PrivatePortraitValidateSession {
|
|||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface PrivatePortraitProjectCreateWithValidateOut {
|
export interface PrivatePortraitProjectCreateWithValidateOut {
|
||||||
project: PrivatePortraitProject;
|
project: PrivatePortraitProject;
|
||||||
validateSession: PrivatePortraitValidateSession;
|
validateSession: PrivatePortraitValidateSession;
|
||||||
@@ -314,17 +326,30 @@ export interface PrivatePortraitAsset {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
projectName?: string | null;
|
projectName?: string | null;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
|
libraryType?: PrivatePortraitLibraryType | string;
|
||||||
remoteGroupId: string;
|
remoteGroupId: string;
|
||||||
remoteAssetId?: string | null;
|
remoteAssetId?: string | null;
|
||||||
remoteProjectName?: string | null;
|
remoteProjectName?: string | null;
|
||||||
assetType: string;
|
assetType: PrivatePortraitAssetType | string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
previewUrl?: string | null;
|
previewUrl?: string | null;
|
||||||
|
displayUrl?: string | null;
|
||||||
|
providerUrl?: string | null;
|
||||||
remoteUrl?: string | null;
|
remoteUrl?: string | null;
|
||||||
|
remoteUrlExpiredAt?: string | null;
|
||||||
|
videoDuration?: number | null;
|
||||||
|
videoCoverUrl?: string | null;
|
||||||
|
fileSize?: number | null;
|
||||||
|
mimeType?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
moderation?: unknown;
|
||||||
|
lastPollAt?: string | null;
|
||||||
|
nextPollAt?: string | null;
|
||||||
pollCount: number;
|
pollCount: number;
|
||||||
remoteDeleteStatus: string;
|
remoteDeleteStatus: string;
|
||||||
|
remoteDeletedAt?: string | null;
|
||||||
|
remoteDeleteError?: string | null;
|
||||||
errorMessage?: string | null;
|
errorMessage?: string | null;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
@@ -341,9 +366,14 @@ export interface PrivatePortraitSelectableAsset {
|
|||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
|
libraryType?: PrivatePortraitLibraryType | string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
assetType: string;
|
assetType: PrivatePortraitAssetType | string;
|
||||||
previewUrl?: string | null;
|
previewUrl?: string | null;
|
||||||
|
displayUrl?: string | null;
|
||||||
|
providerUrl?: string | null;
|
||||||
|
videoDuration?: number | null;
|
||||||
|
videoCoverUrl?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
}
|
}
|
||||||
@@ -354,3 +384,4 @@ export interface PrivatePortraitSelectableAssetListOut {
|
|||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user