Merge branch 'main' of https://gitee.com/wg123/video-gen
@@ -1,37 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<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.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>后台管理</title>
|
||||
<script>
|
||||
(function() {
|
||||
var cached = localStorage.getItem('siteInfo');
|
||||
if (cached) {
|
||||
try {
|
||||
var info = JSON.parse(cached);
|
||||
if (info.siteName) {
|
||||
document.title = info.siteName + ' - 管理后台';
|
||||
}
|
||||
if (info.siteLogo) {
|
||||
var link = document.querySelector('link[rel="icon"]');
|
||||
if (link) {
|
||||
link.href = info.siteLogo;
|
||||
link.type = 'image/png';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-_9WDiveb.js"></script>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<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.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>后台管理</title>
|
||||
<script>
|
||||
(function() {
|
||||
var cached = localStorage.getItem('siteInfo');
|
||||
if (cached) {
|
||||
try {
|
||||
var info = JSON.parse(cached);
|
||||
if (info.siteName) {
|
||||
document.title = info.siteName + ' - 管理后台';
|
||||
}
|
||||
if (info.siteLogo) {
|
||||
var link = document.querySelector('link[rel="icon"]');
|
||||
if (link) {
|
||||
link.href = info.siteLogo;
|
||||
link.type = 'image/png';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-B0tEuCkU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>␍
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
|
||||
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
|
||||
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
|
||||
AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene,
|
||||
} from '../types';
|
||||
|
||||
import type {
|
||||
@@ -251,39 +252,28 @@ export async function deleteUserResourceCapacity(userId: string): Promise<AdminU
|
||||
return api.delete(`/admin/users/${userId}/resource-capacity`);
|
||||
}
|
||||
|
||||
export async function uploadPdf(file: File, configKey: string): Promise<{ url: string }> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('config_key', configKey);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const res = await fetch(`${baseUrl}/api/admin/upload-pdf`, {
|
||||
method: 'POST',
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || '上传失败');
|
||||
export async function uploadAdminFile(
|
||||
file: File,
|
||||
options: { scene: AdminUploadScene; resourceType: AdminUploadResourceType; durationSeconds?: number | null },
|
||||
): Promise<AdminUploadFileResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('scene', options.scene);
|
||||
form.append('resource_type', options.resourceType);
|
||||
if (options.durationSeconds !== undefined && options.durationSeconds !== null) {
|
||||
form.append('duration_seconds', String(options.durationSeconds));
|
||||
}
|
||||
return res.json();
|
||||
return api.post<AdminUploadFileResult>('/admin/uploads/files', form);
|
||||
}
|
||||
|
||||
export async function uploadPdf(file: File, _configKey?: string): Promise<{ url: string }> {
|
||||
const res = await uploadAdminFile(file, { scene: 'system_pdf', resourceType: 'pdf' });
|
||||
return { url: res.url };
|
||||
}
|
||||
|
||||
export async function uploadLogo(file: File): Promise<{ url: string }> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const res = await fetch(`${baseUrl}/api/admin/upload-logo`, {
|
||||
method: 'POST',
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || '上传失败');
|
||||
}
|
||||
return res.json();
|
||||
const res = await uploadAdminFile(file, { scene: 'system_logo', resourceType: 'image' });
|
||||
return { url: res.url };
|
||||
}
|
||||
|
||||
function setMaybe(params: URLSearchParams, key: string, value: unknown): void {
|
||||
@@ -757,17 +747,8 @@ export async function getOAuthList(params: OAuthListParams): Promise<any> {
|
||||
}
|
||||
|
||||
export async function uploadImage(file: File): Promise<{ url: string; filename: string }> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-image`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('图片上传失败');
|
||||
const data = await res.json();
|
||||
return { url: data.url, filename: data.filename };
|
||||
const res = await uploadAdminFile(file, { scene: 'open_type_thumb', resourceType: 'image' });
|
||||
return { url: res.url, filename: res.originalFileName || res.fileName };
|
||||
}
|
||||
|
||||
// 自定义表头字段
|
||||
@@ -900,6 +881,8 @@ export async function uploadHomeMaterialAsset(payload: HomeMaterialUploadAssetPa
|
||||
form.append('file', payload.file);
|
||||
form.append('media_type', payload.mediaType);
|
||||
if (payload.title && payload.title.trim()) form.append('title', payload.title.trim());
|
||||
if (payload.generationPrompt && payload.generationPrompt.trim()) form.append('generation_prompt', payload.generationPrompt.trim());
|
||||
if (payload.mediaReferences && payload.mediaReferences.length > 0) form.append('media_references_json', JSON.stringify(payload.mediaReferences));
|
||||
form.append('watermark_type', payload.watermarkType);
|
||||
|
||||
if (payload.watermarkType === 'image') {
|
||||
@@ -1020,6 +1003,17 @@ export async function getPreTestTemplateList(params: PreTestTemplateListParams):
|
||||
return api.get(`/material-admin/pre-test-template-list?${query.toString()}`);
|
||||
}
|
||||
|
||||
export interface GetAreaParams {
|
||||
level?: string;
|
||||
parent_code?: string;
|
||||
}
|
||||
export async function getArea(params?: GetAreaParams): Promise<any> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.level) query.set('level', params.level);
|
||||
if (params?.parent_code) query.set('parent_code', params.parent_code);
|
||||
return api.get(`/pre-test-template/getArea?${query.toString()}`);
|
||||
}
|
||||
|
||||
// ── Private Portrait Admin ────────────────────────────────
|
||||
export async function adminGetPrivatePortraitProjects(params: { userId?: string; libraryType?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitProjectListOut> {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
@@ -25,6 +25,9 @@ interface CreditRatio {
|
||||
inputVideoRatio: number;
|
||||
inputVideoBaseCredits: number;
|
||||
inputVideoPerSecondCredits: number;
|
||||
inputImageRatio: number;
|
||||
inputImageBaseCredits: number;
|
||||
inputImagePerImageCredits: number;
|
||||
}
|
||||
|
||||
interface CreditRatioFormValues {
|
||||
@@ -37,6 +40,9 @@ interface CreditRatioFormValues {
|
||||
inputVideoRatio?: number;
|
||||
inputVideoBaseCredits?: number;
|
||||
inputVideoPerSecondCredits?: number;
|
||||
inputImageRatio?: number;
|
||||
inputImageBaseCredits?: number;
|
||||
inputImagePerImageCredits?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_IMAGE_SIZES = ['2K', '4K'];
|
||||
@@ -136,6 +142,9 @@ const AdminCreditRatios: React.FC = () => {
|
||||
payload.input_video_base_credits = values.inputVideoBaseCredits ?? 0;
|
||||
payload.input_video_per_second_credits = values.inputVideoPerSecondCredits ?? 0;
|
||||
}
|
||||
payload.input_image_ratio = values.inputImageRatio ?? 1.0;
|
||||
payload.input_image_base_credits = values.inputImageBaseCredits ?? 0;
|
||||
payload.input_image_per_image_credits = values.inputImagePerImageCredits ?? 0;
|
||||
if (modal.ratio) {
|
||||
await saveCreditRatio({ id: modal.ratio.id, ...payload });
|
||||
message.success('已更新');
|
||||
@@ -188,6 +197,9 @@ const AdminCreditRatios: React.FC = () => {
|
||||
inputVideoRatio: ratio.inputVideoRatio,
|
||||
inputVideoBaseCredits: ratio.inputVideoBaseCredits,
|
||||
inputVideoPerSecondCredits: ratio.inputVideoPerSecondCredits,
|
||||
inputImageRatio: ratio.inputImageRatio,
|
||||
inputImageBaseCredits: ratio.inputImageBaseCredits,
|
||||
inputImagePerImageCredits: ratio.inputImagePerImageCredits,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
@@ -199,6 +211,9 @@ const AdminCreditRatios: React.FC = () => {
|
||||
inputVideoRatio: 1.0,
|
||||
inputVideoBaseCredits: 0,
|
||||
inputVideoPerSecondCredits: 0.5,
|
||||
inputImageRatio: 1.0,
|
||||
inputImageBaseCredits: 0,
|
||||
inputImagePerImageCredits: 0.5,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -238,31 +253,60 @@ const AdminCreditRatios: React.FC = () => {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '视频倍率', dataIndex: 'inputVideoRatio', width: 100,
|
||||
render: (v: number, r: CreditRatio) => (
|
||||
<Typography.Text>{r.genType === 'image' ? '-' : (
|
||||
<span style={{ color: v >= 2 ? '#ef4444' : v >= 1.5 ? '#f59e0b' : '#10b981' }}>x{v}</span>
|
||||
)}</Typography.Text>
|
||||
),
|
||||
title: '传入视频',
|
||||
children: [
|
||||
{
|
||||
title: '倍率', dataIndex: 'inputVideoRatio', width: 90,
|
||||
render: (v: number, r: CreditRatio) => (
|
||||
<Typography.Text>{r.genType === 'image' ? '-' : (
|
||||
<span style={{ color: v >= 2 ? '#ef4444' : v >= 1.5 ? '#f59e0b' : '#10b981' }}>x{v}</span>
|
||||
)}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '基础积分', dataIndex: 'inputVideoBaseCredits', width: 100,
|
||||
render: (v: number, r: CreditRatio) => (
|
||||
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分`}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '每秒积分', dataIndex: 'inputVideoPerSecondCredits', width: 100,
|
||||
render: (v: number, r: CreditRatio) => (
|
||||
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分/秒`}</Typography.Text>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '视频基础积分', dataIndex: 'inputVideoBaseCredits', width: 110,
|
||||
render: (v: number, r: CreditRatio) => (
|
||||
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分`}</Typography.Text>
|
||||
),
|
||||
title: '传入图片',
|
||||
children: [
|
||||
{
|
||||
title: '倍率', dataIndex: 'inputImageRatio', width: 90,
|
||||
render: (v: number) => (
|
||||
<span style={{ color: v >= 2 ? '#ef4444' : v >= 1.5 ? '#f59e0b' : '#10b981' }}>x{v}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '基础积分', dataIndex: 'inputImageBaseCredits', width: 100,
|
||||
render: (v: number) => <Typography.Text>{v} 积分</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '每张积分', dataIndex: 'inputImagePerImageCredits', width: 100,
|
||||
render: (v: number) => <Typography.Text>{v} 积分/张</Typography.Text>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '视频每秒积分', dataIndex: 'inputVideoPerSecondCredits', width: 110,
|
||||
render: (v: number, r: CreditRatio) => (
|
||||
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分/秒`}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '示例计算(视频15秒,上传视频15秒)', key: 'example', width: 140,
|
||||
title: '示例计算', key: 'example', width: 160,
|
||||
render: (_: any, r: CreditRatio) => {
|
||||
let total: number;
|
||||
if (r.genType === 'image') {
|
||||
total = Math.round(r.baseCredits * r.ratio);
|
||||
if (r.inputImageRatio && r.inputImagePerImageCredits) {
|
||||
total += Math.round(
|
||||
(r.inputImageBaseCredits + r.inputImagePerImageCredits * 3) * r.inputImageRatio
|
||||
);
|
||||
}
|
||||
} else {
|
||||
total = Math.round((r.baseCredits + r.perSecondCredits * 15) * r.ratio);
|
||||
if (r.inputVideoRatio && r.inputVideoPerSecondCredits) {
|
||||
@@ -270,6 +314,11 @@ const AdminCreditRatios: React.FC = () => {
|
||||
(r.inputVideoBaseCredits + r.inputVideoPerSecondCredits * 15) * r.inputVideoRatio
|
||||
);
|
||||
}
|
||||
if (r.inputImageRatio && r.inputImagePerImageCredits) {
|
||||
total += Math.round(
|
||||
(r.inputImageBaseCredits + r.inputImagePerImageCredits * 3) * r.inputImageRatio
|
||||
);
|
||||
}
|
||||
}
|
||||
return <Typography.Text strong style={{ color: '#6366f1' }}>{total} 积分</Typography.Text>;
|
||||
},
|
||||
@@ -346,7 +395,7 @@ const AdminCreditRatios: React.FC = () => {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 1180 }}
|
||||
scroll={{ x: 1500 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -421,6 +470,29 @@ const AdminCreditRatios: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
marginTop: 12,
|
||||
padding: '12px 16px',
|
||||
backgroundColor: '#f0fff4',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #c6f6d5',
|
||||
}}>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8, color: '#059669' }}>
|
||||
传入图片价格
|
||||
</Typography.Text>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="inputImageRatio" label="倍率" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入图片倍率' }]}>
|
||||
<InputNumber min={0} max={10} step={0.1} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="inputImageBaseCredits" label="基础积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入图片基础积分' }]}>
|
||||
<InputNumber min={0} max={500} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="inputImagePerImageCredits" label="每张积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入图片每张积分' }]}>
|
||||
<InputNumber min={0} max={50} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -46,37 +46,35 @@ const AdminMaterialList: React.FC = () => {
|
||||
key: 'advertiserId',
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'note',
|
||||
key: 'note',
|
||||
width: 200,
|
||||
render: (text: string) => (
|
||||
<Input.TextArea
|
||||
value={text || ''}
|
||||
readOnly
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
style={{ color: '#94a3b8', resize: 'none', border: 'none', background: 'transparent', padding: 0 }}
|
||||
placeholder="-"
|
||||
/>
|
||||
),
|
||||
title: '素材ID',
|
||||
dataIndex: 'materialId',
|
||||
key: 'materialId',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '授权ID',
|
||||
dataIndex: 'oauthId',
|
||||
key: 'oauthId',
|
||||
},
|
||||
{
|
||||
title: '预测试结果',
|
||||
dataIndex: 'preResult',
|
||||
key: 'preResult',
|
||||
width: 120,
|
||||
render: (text: string) => <PreResultDisplay preResult={text} />,
|
||||
title: '上传ID',
|
||||
dataIndex: 'uploadId',
|
||||
key: 'uploadId',
|
||||
render: (v: string) => {
|
||||
if (!v) return '-';
|
||||
const short = v.length > 12 ? `${v.slice(0, 6)}...${v.slice(-4)}` : v;
|
||||
return (
|
||||
<Tooltip title={v} placement="topLeft">
|
||||
<Typography.Text style={{ fontSize: 12 }}>{short}</Typography.Text>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '预测试模板ID',
|
||||
dataIndex: 'preTestTemplateId',
|
||||
key: 'preTestTemplateId',
|
||||
},
|
||||
{
|
||||
title: '授权ID',
|
||||
dataIndex: 'oauthId',
|
||||
key: 'oauthId',
|
||||
},
|
||||
{
|
||||
title: '目标标ID',
|
||||
dataIndex: 'targetId',
|
||||
@@ -98,24 +96,26 @@ const AdminMaterialList: React.FC = () => {
|
||||
key: 'userId',
|
||||
},
|
||||
{
|
||||
title: '素材ID',
|
||||
dataIndex: 'materialId',
|
||||
key: 'materialId',
|
||||
width: 180,
|
||||
title: '备注',
|
||||
dataIndex: 'note',
|
||||
key: 'note',
|
||||
width: 200,
|
||||
render: (text: string) => (
|
||||
<Input.TextArea
|
||||
value={text || ''}
|
||||
readOnly
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
style={{ color: '#94a3b8', resize: 'none', border: 'none', background: 'transparent', padding: 0 }}
|
||||
placeholder="-"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '上传ID',
|
||||
dataIndex: 'uploadId',
|
||||
key: 'uploadId',
|
||||
render: (v: string) => {
|
||||
if (!v) return '-';
|
||||
const short = v.length > 12 ? `${v.slice(0, 6)}...${v.slice(-4)}` : v;
|
||||
return (
|
||||
<Tooltip title={v} placement="topLeft">
|
||||
<Typography.Text style={{ fontSize: 12 }}>{short}</Typography.Text>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
title: '预测试结果',
|
||||
dataIndex: 'preResult',
|
||||
key: 'preResult',
|
||||
width: 120,
|
||||
render: (text: string) => <PreResultDisplay preResult={text} />,
|
||||
},
|
||||
{
|
||||
title: '资源类型',
|
||||
|
||||
@@ -1,8 +1,119 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Input, Table, Pagination, Tag, Typography, DatePicker } from 'antd';
|
||||
import { Button, Input, Table, Pagination, Tag, Typography, DatePicker, message, Tooltip } from 'antd';
|
||||
import { FileTextOutlined } from '@ant-design/icons';
|
||||
import { getPreTestTemplateList } from '../api';
|
||||
import { getPreTestTemplateList, getArea } from '../api';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface PreTestField {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const PRETEST_FIELDS: PreTestField[] = [
|
||||
{ id: 'AD_APP_ACTIVATE', name: 'AD_APP_ACTIVATE', label: '应用-激活', description: '应用激活行为' },
|
||||
{ id: 'AD_APP_AUTH', name: 'AD_APP_AUTH', label: '应用-授信', description: '应用授信行为' },
|
||||
{ id: 'AD_APP_BOOK', name: 'AD_APP_BOOK', label: '应用-预约表单', description: '应用预约表单' },
|
||||
{ id: 'AD_APP_BUY', name: 'AD_APP_BUY', label: '应用-APP内付费', description: 'APP内付费行为' },
|
||||
{ id: 'AD_APP_CLICKS', name: 'AD_APP_CLICKS', label: '应用-点击量', description: '应用点击量' },
|
||||
{ id: 'AD_APP_DETAIL', name: 'AD_APP_DETAIL', label: '应用-APP内详情页到站UV', description: 'APP内详情页到站UV' },
|
||||
{ id: 'AD_APP_DOWNLOADED', name: 'AD_APP_DOWNLOADED', label: '应用-下载完成', description: '应用下载完成' },
|
||||
{ id: 'AD_APP_INSTALLED', name: 'AD_APP_INSTALLED', label: '应用-安装完成', description: '应用安装完成' },
|
||||
{ id: 'AD_APP_KEY_BEHAVIOR', name: 'AD_APP_KEY_BEHAVIOR', label: '应用-关键行为', description: '应用关键行为' },
|
||||
{ id: 'AD_APP_ORDER', name: 'AD_APP_ORDER', label: '应用-APP内下单', description: 'APP内下单行为' },
|
||||
{ id: 'AD_APP_PAY', name: 'AD_APP_PAY', label: '应用-付费', description: '应用付费行为' },
|
||||
{ id: 'AD_APP_PRE_AUTH', name: 'AD_APP_PRE_AUTH', label: '应用-预授信', description: '应用预授信行为' },
|
||||
{ id: 'AD_APP_PRE_DOWNLOAD', name: 'AD_APP_PRE_DOWNLOAD', label: '应用-预约下载', description: '应用预约下载' },
|
||||
{ id: 'AD_APP_PUSH_ORDER', name: 'AD_APP_PUSH_ORDER', label: '应用-首次发单(乘客)', description: '首次发单行为' },
|
||||
{ id: 'AD_APP_REGISTER', name: 'AD_APP_REGISTER', label: '应用-注册', description: '应用注册行为' },
|
||||
{ id: 'AD_APP_SHOW', name: 'AD_APP_SHOW', label: '应用-展示量', description: '应用展示量' },
|
||||
{ id: 'AD_APP_SUBMIT', name: 'AD_APP_SUBMIT', label: '应用-提交认证', description: '应用提交认证' },
|
||||
{ id: 'AD_APP_VIEW', name: 'AD_APP_VIEW', label: '应用-APP内访问', description: 'APP内访问行为' },
|
||||
{ id: 'AD_CLUE_AUTH', name: 'AD_CLUE_AUTH', label: '销售线索收集-授信', description: '销售线索授信' },
|
||||
{ id: 'AD_CLUE_BUTTON', name: 'AD_CLUE_BUTTON', label: '销售线索收集-按钮跳转', description: '按钮跳转' },
|
||||
{ id: 'AD_CLUE_CLICKS', name: 'AD_CLUE_CLICKS', label: '销售线索收集-点击量', description: '点击量' },
|
||||
{ id: 'AD_CLUE_CONFIRM', name: 'AD_CLUE_CONFIRM', label: '销售线索收集-回访-信息确认', description: '回访信息确认' },
|
||||
{ id: 'AD_CLUE_CONSULT', name: 'AD_CLUE_CONSULT', label: '销售线索收集-有效咨询', description: '有效咨询' },
|
||||
{ id: 'AD_CLUE_CONSULT_MSG', name: 'AD_CLUE_CONSULT_MSG', label: '销售线索收集-留资咨询', description: '留资咨询' },
|
||||
{ id: 'AD_CLUE_COUPON', name: 'AD_CLUE_COUPON', label: '销售线索收集-卡券领取', description: '卡券领取' },
|
||||
{ id: 'AD_CLUE_CUSTOMER', name: 'AD_CLUE_CUSTOMER', label: '销售线索收集-有效获客', description: '有效获客' },
|
||||
{ id: 'AD_CLUE_CVT', name: 'AD_CLUE_CVT', label: '销售线索收集-多转化', description: '多转化' },
|
||||
{ id: 'AD_CLUE_DONE', name: 'AD_CLUE_DONE', label: '销售线索收集-完件', description: '完件' },
|
||||
{ id: 'AD_CLUE_FORM', name: 'AD_CLUE_FORM', label: '销售线索收集-表单提交', description: '表单提交' },
|
||||
{ id: 'AD_CLUE_FRIENDS', name: 'AD_CLUE_FRIENDS', label: '销售线索收集-回访-加为好友', description: '回访加为好友' },
|
||||
{ id: 'AD_CLUE_INSURANCE', name: 'AD_CLUE_INSURANCE', label: '销售线索收集-保险支付', description: '保险支付' },
|
||||
{ id: 'AD_CLUE_INTENTION', name: 'AD_CLUE_INTENTION', label: '销售线索收集-存在意向', description: '存在意向' },
|
||||
{ id: 'AD_CLUE_INTENTION_FORM', name: 'AD_CLUE_INTENTION_FORM', label: '销售线索收集-意向表单', description: '意向表单' },
|
||||
{ id: 'AD_CLUE_INTENTION_TEL', name: 'AD_CLUE_INTENTION_TEL', label: '销售线索收集-意向话单', description: '意向话单' },
|
||||
{ id: 'AD_CLUE_MESSAGE', name: 'AD_CLUE_MESSAGE', label: '销售线索收集-私信消息', description: '私信消息' },
|
||||
{ id: 'AD_CLUE_MONEY', name: 'AD_CLUE_MONEY', label: '销售线索收集-放款', description: '放款' },
|
||||
{ id: 'AD_CLUE_MSG', name: 'AD_CLUE_MSG', label: '销售线索收集-私信留资', description: '私信留资' },
|
||||
{ id: 'AD_CLUE_PAGE', name: 'AD_CLUE_PAGE', label: '销售线索收集-访问目标页面', description: '访问目标页面' },
|
||||
{ id: 'AD_CLUE_PAY', name: 'AD_CLUE_PAY', label: '销售线索收集-付费', description: '付费' },
|
||||
{ id: 'AD_CLUE_PRE_AUTH', name: 'AD_CLUE_PRE_AUTH', label: '销售线索收集-预授信', description: '预授信' },
|
||||
{ id: 'AD_CLUE_PROTENTIAL_DEAL', name: 'AD_CLUE_PROTENTIAL_DEAL', label: '销售线索收集-回访-高潜成交', description: '回访高潜成交' },
|
||||
{ id: 'AD_CLUE_PUSH_ORDER', name: 'AD_CLUE_PUSH_ORDER', label: '销售线索收集-首次发单(乘客)', description: '首次发单' },
|
||||
{ id: 'AD_CLUE_REGISTER', name: 'AD_CLUE_REGISTER', label: '销售线索收集-注册', description: '注册' },
|
||||
{ id: 'AD_CLUE_SHOW', name: 'AD_CLUE_SHOW', label: '销售线索收集-展示量', description: '展示量' },
|
||||
{ id: 'AD_CLUE_SUBMIT', name: 'AD_CLUE_SUBMIT', label: '销售线索收集-提交认证', description: '提交认证' },
|
||||
{ id: 'AD_CLUE_TEL', name: 'AD_CLUE_TEL', label: '销售线索收集-智能电话确认接通', description: '智能电话确认接通' },
|
||||
{ id: 'AD_CLUE_TEL_CALL', name: 'AD_CLUE_TEL_CALL', label: '销售线索收集-电话接通', description: '电话接通' },
|
||||
{ id: 'AD_CLUE_WX_ADD', name: 'AD_CLUE_WX_ADD', label: '销售线索收集-微信-添加企业微信', description: '添加企业微信' },
|
||||
{ id: 'AD_CLUE_WX_COPY', name: 'AD_CLUE_WX_COPY', label: '销售线索收集-微信复制', description: '微信复制' },
|
||||
{ id: 'AD_CLUE_WX_MSG', name: 'AD_CLUE_WX_MSG', label: '销售线索收集-微信-用户首次消息', description: '用户首次消息' },
|
||||
{ id: 'AD_ECP_APP_BUY', name: 'AD_ECP_APP_BUY', label: '电商-app内下单', description: '电商app内下单' },
|
||||
{ id: 'AD_ECP_APP_DETAIL', name: 'AD_ECP_APP_DETAIL', label: '电商-app内详情页到站uv', description: '电商app内详情页到站uv' },
|
||||
{ id: 'AD_ECP_APP_VIEW', name: 'AD_ECP_APP_VIEW', label: '电商-app内访问', description: '电商app内访问' },
|
||||
{ id: 'AD_ECP_BUTTON', name: 'AD_ECP_BUTTON', label: '电商-按钮跳转', description: '电商按钮跳转' },
|
||||
{ id: 'AD_ECP_INTEREST', name: 'AD_ECP_INTEREST', label: '电商-引流电商种草', description: '引流电商种草' },
|
||||
{ id: 'AD_ECP_SHOP', name: 'AD_ECP_SHOP', label: '电商-调起店铺', description: '调起店铺' },
|
||||
{ id: 'AD_ECP_SHOP_STAY', name: 'AD_ECP_SHOP_STAY', label: '电商-店铺停留', description: '店铺停留' },
|
||||
{ id: 'AD_MINIAPP_ACTIVATE', name: 'AD_MINIAPP_ACTIVATE', label: '快应用-激活', description: '快应用激活' },
|
||||
{ id: 'AD_MINIAPP_KEY_BEHAVIOR', name: 'AD_MINIAPP_KEY_BEHAVIOR', label: '快应用-关键行为', description: '快应用关键行为' },
|
||||
{ id: 'AD_MINIAPP_PAY', name: 'AD_MINIAPP_PAY', label: '快应用-付费', description: '快应用付费' },
|
||||
{ id: 'AD_MINIAPP_REGISTER', name: 'AD_MINIAPP_REGISTER', label: '快应用-注册', description: '快应用注册' },
|
||||
{ id: 'AD_NATIVE_ACTIVATE', name: 'AD_NATIVE_ACTIVATE', label: '原声互动-激活', description: '原声互动激活' },
|
||||
{ id: 'AD_NATIVE_CLICKS', name: 'AD_NATIVE_CLICKS', label: '原声互动-组件点击', description: '组件点击' },
|
||||
{ id: 'AD_NATIVE_FANS_GROUP', name: 'AD_NATIVE_FANS_GROUP', label: '原声互动-粉丝入群', description: '粉丝入群' },
|
||||
{ id: 'AD_NATIVE_FOLLOW', name: 'AD_NATIVE_FOLLOW', label: '原声互动-帐号关注', description: '帐号关注' },
|
||||
{ id: 'AD_NATIVE_INTERACTIVE', name: 'AD_NATIVE_INTERACTIVE', label: '原声互动-互动', description: '互动' },
|
||||
{ id: 'AD_NATIVE_LIVE', name: 'AD_NATIVE_LIVE', label: '原声互动-预约直播', description: '预约直播' },
|
||||
{ id: 'AD_NATIVE_LIVE_DONATE', name: 'AD_NATIVE_LIVE_DONATE', label: '原声互动-直播间营销捐赠', description: '直播间营销捐赠' },
|
||||
{ id: 'AD_NATIVE_LIVE_PAY', name: 'AD_NATIVE_LIVE_PAY', label: '原声互动-直播间打赏', description: '直播间打赏' },
|
||||
{ id: 'AD_NATIVE_LIVE_STAY', name: 'AD_NATIVE_LIVE_STAY', label: '原声互动-直播间停留', description: '直播间停留' },
|
||||
{ id: 'AD_NATIVE_LIVE_VIEW', name: 'AD_NATIVE_LIVE_VIEW', label: '原声互动-直播间观看', description: '直播间观看' },
|
||||
{ id: 'AD_NATIVE_PAY', name: 'AD_NATIVE_PAY', label: '原声互动-付费', description: '原声互动付费' },
|
||||
{ id: 'AD_PRODUCT_ACTIVATE', name: 'AD_PRODUCT_ACTIVATE', label: '商品-激活', description: '商品激活' },
|
||||
{ id: 'AD_PRODUCT_APP_BUY', name: 'AD_PRODUCT_APP_BUY', label: '商品-app内下单', description: '商品app内下单' },
|
||||
{ id: 'AD_PRODUCT_APP_DETAIL', name: 'AD_PRODUCT_APP_DETAIL', label: '商品-app内详情页到站uv', description: '商品app内详情页到站uv' },
|
||||
{ id: 'AD_PRODUCT_APP_PAY', name: 'AD_PRODUCT_APP_PAY', label: '商品-app内付费', description: '商品app内付费' },
|
||||
{ id: 'AD_PRODUCT_APP_VIEW', name: 'AD_PRODUCT_APP_VIEW', label: '商品-app内访问', description: '商品app内访问' },
|
||||
{ id: 'AD_PRODUCT_FORM', name: 'AD_PRODUCT_FORM', label: '商品-表单提交', description: '商品表单提交' },
|
||||
{ id: 'AD_PRODUCT_KEY_BEHAVIOR', name: 'AD_PRODUCT_KEY_BEHAVIOR', label: '商品-关键行为', description: '商品关键行为' },
|
||||
{ id: 'AD_PRODUCT_PAY', name: 'AD_PRODUCT_PAY', label: '商品-付费', description: '商品付费' },
|
||||
{ id: 'AD_TINYAPP_ACTIVATE', name: 'AD_TINYAPP_ACTIVATE', label: '小程序-激活', description: '小程序激活' },
|
||||
{ id: 'AD_TINYAPP_KEY_BEHAVIOR', name: 'AD_TINYAPP_KEY_BEHAVIOR', label: '小程序-关键行为', description: '小程序关键行为' },
|
||||
{ id: 'AD_TINYAPP_PAY', name: 'AD_TINYAPP_PAY', label: '小程序-付费', description: '小程序付费' },
|
||||
{ id: 'QC_LIVE_BUY', name: 'QC_LIVE_BUY', label: '直播投放-直播间下单', description: '直播间下单' },
|
||||
{ id: 'QC_LIVE_CHECK', name: 'QC_LIVE_CHECK', label: '直播投放-直播间结算', description: '直播间结算' },
|
||||
{ id: 'QC_LIVE_COMMENTS', name: 'QC_LIVE_COMMENTS', label: '直播投放-直播间评论', description: '直播间评论' },
|
||||
{ id: 'QC_LIVE_DEAL', name: 'QC_LIVE_DEAL', label: '直播投放-直播间成交', description: '直播间成交' },
|
||||
{ id: 'QC_LIVE_ENTRY', name: 'QC_LIVE_ENTRY', label: '直播投放-进入直播间', description: '进入直播间' },
|
||||
{ id: 'QC_LIVE_FANS', name: 'QC_LIVE_FANS', label: '直播投放-直播间粉丝提升', description: '直播间粉丝提升' },
|
||||
{ id: 'QC_LIVE_HIT', name: 'QC_LIVE_HIT', label: '直播投放-直播加热', description: '直播加热' },
|
||||
{ id: 'QC_LIVE_PRODUCT_CLICKS', name: 'QC_LIVE_PRODUCT_CLICKS', label: '直播投放-直播间商品点击', description: '直播间商品点击' },
|
||||
{ id: 'QC_LIVE_ROI_CHECK', name: 'QC_LIVE_ROI_CHECK', label: '直播投放-结算roi', description: '结算roi' },
|
||||
{ id: 'QC_LIVE_ROI_DEAL', name: 'QC_LIVE_ROI_DEAL', label: '直播投放-支付roi-直播间成交', description: '支付roi直播间成交' },
|
||||
{ id: 'QC_LIVE_ROI_QC', name: 'QC_LIVE_ROI_QC', label: '直播投放-支付roi-千川直接+间接订单', description: '支付roi千川订单' },
|
||||
{ id: 'QC_PRODUCT_BUY', name: 'QC_PRODUCT_BUY', label: '商品投放-商品购买', description: '商品购买' },
|
||||
{ id: 'QC_PRODUCT_COMMENTS', name: 'QC_PRODUCT_COMMENTS', label: '商品投放-点赞评论', description: '点赞评论' },
|
||||
{ id: 'QC_PRODUCT_FANS', name: 'QC_PRODUCT_FANS', label: '商品投放-粉丝提升', description: '粉丝提升' },
|
||||
{ id: 'QC_PRODUCT_INTEREST', name: 'QC_PRODUCT_INTEREST', label: '商品投放-人群种草', description: '人群种草' },
|
||||
{ id: 'QC_PRODUCT_QC', name: 'QC_PRODUCT_QC', label: '商品投放-千川直接+间接订单', description: '千川订单' },
|
||||
{ id: 'QC_PRODUCT_ROI', name: 'QC_PRODUCT_ROI', label: '商品投放-商品支付roi', description: '商品支付roi' },
|
||||
];
|
||||
|
||||
const formatDateTime = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
@@ -24,6 +135,48 @@ const AdminPreTestTemplates: React.FC = () => {
|
||||
const [searchId, setSearchId] = useState('');
|
||||
const [searchPhone, setSearchPhone] = useState('');
|
||||
const [searchCreatedAt, setSearchCreatedAt] = useState<[dayjs.Dayjs, dayjs.Dayjs] | undefined>();
|
||||
const [cityCodeMap, setCityCodeMap] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
loadProvinceOptions();
|
||||
}, []);
|
||||
|
||||
const loadProvinceOptions = async () => {
|
||||
try {
|
||||
const cachedData = localStorage.getItem('preTestAreaData');
|
||||
if (cachedData) {
|
||||
const parsed = JSON.parse(cachedData);
|
||||
setCityCodeMap(parsed.cityCodeMap);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await getArea({ level: 'ONE_LEVEL' });
|
||||
const provinces = res.data || [];
|
||||
const map: Record<string, string> = {};
|
||||
await Promise.all(
|
||||
provinces.map(async (province: any) => {
|
||||
let cities: any[] = [];
|
||||
try {
|
||||
const cityRes = await getArea({ level: 'TWO_LEVEL', parent_code: province.code });
|
||||
cities = cityRes.data || [];
|
||||
cities.forEach((city: any) => {
|
||||
map[city.code] = city.name;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`获取省份 ${province.name} 的城市失败`, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const cacheData = { cityCodeMap: map };
|
||||
localStorage.setItem('preTestAreaData', JSON.stringify(cacheData));
|
||||
setCityCodeMap(map);
|
||||
} catch (error) {
|
||||
console.error('加载区域数据失败:', error);
|
||||
message.error('加载区域数据失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '序号',
|
||||
@@ -36,12 +189,53 @@ const AdminPreTestTemplates: React.FC = () => {
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 120,
|
||||
render: (v: string) => {
|
||||
if (!v) return '-';
|
||||
const short = v.length > 12 ? `${v.slice(0, 6)}...${v.slice(-4)}` : v;
|
||||
return (
|
||||
<Tooltip title={v} placement="topLeft">
|
||||
<Typography.Text style={{ fontSize: 12 }}>{short}</Typography.Text>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
// {
|
||||
// title: '手机号',
|
||||
// dataIndex: 'phone',
|
||||
// key: 'phone',
|
||||
// width: 150,
|
||||
// },
|
||||
{
|
||||
title: '投放平台',
|
||||
dataIndex: 'platform',
|
||||
key: 'platform',
|
||||
width: 100,
|
||||
render: (text: string) => (
|
||||
<Tag color={text === 'AD' ? 'blue' : text === 'QIANCHUAN' ? 'green' : 'orange'} style={{ borderRadius: 6, fontSize: 12 }}>
|
||||
{text === 'AD' ? 'AD' : text === 'QIANCHUAN' ? '千川' : '本地推'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
width: 150,
|
||||
title: '优化目标',
|
||||
dataIndex: 'externalAction',
|
||||
key: 'externalAction',
|
||||
ellipsis: true,
|
||||
width: 200,
|
||||
render: (text: string) => {
|
||||
if (!text) return <span style={{ color: '#94a3b8', fontSize: 13 }}>-</span>;
|
||||
const field = PRETEST_FIELDS.find(f => f.name === text.trim());
|
||||
return (
|
||||
<span style={{ color: '#1e293b', fontSize: 13 }}>{field ? field.label : text}</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '目标转化成本',
|
||||
dataIndex: 'cpaBid',
|
||||
key: 'cpaBid',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '受众年龄',
|
||||
@@ -65,42 +259,68 @@ const AdminPreTestTemplates: React.FC = () => {
|
||||
title: '受众区域',
|
||||
dataIndex: 'audienceRegion',
|
||||
key: 'audienceRegion',
|
||||
width: 100,
|
||||
ellipsis: true,
|
||||
width: 300,
|
||||
render: (text: string) => {
|
||||
const arr = JSON.parse(text);
|
||||
if (!Array.isArray(arr) || arr.length === 0) {
|
||||
return <span style={{ color: '#94a3b8', fontSize: 13 }}>-</span>;
|
||||
}
|
||||
const cityNames = arr.map(code => {
|
||||
const paddedCode = String(code).padEnd(6, '0');
|
||||
return cityCodeMap[paddedCode] || paddedCode;
|
||||
});
|
||||
return (
|
||||
<Input.TextArea
|
||||
value={cityNames.join(', ') || '-'}
|
||||
readOnly
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
style={{ resize: 'none', border: 'none', background: 'transparent', padding: 0 }}
|
||||
placeholder="-"
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '预算',
|
||||
dataIndex: 'budget',
|
||||
key: 'budget',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '成本上限',
|
||||
dataIndex: 'costCap',
|
||||
key: 'costCap',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: 'CPA预算',
|
||||
dataIndex: 'cpaBid',
|
||||
key: 'cpaBid',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: 'CPC预算',
|
||||
dataIndex: 'cpcBid',
|
||||
key: 'cpcBid',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '客户名称',
|
||||
title: '客户主体名称',
|
||||
dataIndex: 'cusName',
|
||||
key: 'cusName',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '外部操作',
|
||||
dataIndex: 'externalAction',
|
||||
key: 'externalAction',
|
||||
title: '计费方式',
|
||||
dataIndex: 'pricingType',
|
||||
key: 'pricingType',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '是否最优成本出价',
|
||||
dataIndex: 'costCap',
|
||||
key: 'costCap',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '是否稳定成本出价',
|
||||
dataIndex: 'targetCost',
|
||||
key: 'targetCost',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '是否最大转化出价',
|
||||
dataIndex: 'nobid',
|
||||
key: 'nobid',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '目标点击成本',
|
||||
dataIndex: 'cpcBid',
|
||||
key: 'cpcBid',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '预算金额',
|
||||
dataIndex: 'budget',
|
||||
key: 'budget',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
@@ -109,41 +329,26 @@ const AdminPreTestTemplates: React.FC = () => {
|
||||
key: 'name',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'nobid',
|
||||
key: 'nobid',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'note',
|
||||
key: 'note',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '平台',
|
||||
dataIndex: 'platform',
|
||||
key: 'platform',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '定价类型',
|
||||
dataIndex: 'pricingType',
|
||||
key: 'pricingType',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '目标成本',
|
||||
dataIndex: 'targetCost',
|
||||
key: 'targetCost',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '用户ID',
|
||||
dataIndex: 'userId',
|
||||
key: 'userId',
|
||||
width: 100,
|
||||
width: 110,
|
||||
render: (v: string) => {
|
||||
if (!v) return '-';
|
||||
const short = v.length > 12 ? `${v.slice(0, 6)}...${v.slice(-4)}` : v;
|
||||
return (
|
||||
<Tooltip title={v} placement="topLeft">
|
||||
<Typography.Text style={{ fontSize: 12 }}>{short}</Typography.Text>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '用户手机号',
|
||||
|
||||
@@ -105,7 +105,11 @@ const AdminSettings: React.FC = () => {
|
||||
const res = await uploadLogo(file);
|
||||
setConfigs(prev => prev.map(c => c.key === 'site_logo' ? { ...c, value: res.url } : c));
|
||||
form.setFieldsValue({ site_logo: res.url });
|
||||
message.success('Logo上传成功');
|
||||
const config = configs.find(c => c.key === 'site_logo');
|
||||
if (config) {
|
||||
await updateSystemConfig(config.id, res.url);
|
||||
}
|
||||
message.success('Logo上传成功并已保存');
|
||||
} catch {
|
||||
message.error('上传失败');
|
||||
} finally {
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Dropdown, Image, Modal, Select, Space, Table, Tag, message } from 'antd';
|
||||
import { DeleteOutlined, MoreOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { Button, Dropdown, Image, Input, Modal, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
|
||||
import { DeleteOutlined, EditOutlined, MoreOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
deleteHomeMaterialAsset,
|
||||
getHomeMaterialAssetStatus,
|
||||
getHomeMaterialAssets,
|
||||
regenerateHomeMaterialWatermark,
|
||||
updateHomeMaterialAsset,
|
||||
} from '../../api';
|
||||
import type {
|
||||
HomeMaterialAsset,
|
||||
HomeMaterialAssetQueryParams,
|
||||
HomeMaterialCategory,
|
||||
HomeMaterialMediaReference,
|
||||
HomeMaterialWatermark,
|
||||
HomeMaterialWatermarkConfig,
|
||||
HomeMaterialTextWatermarkConfig,
|
||||
} from '../../types';
|
||||
import { apiUrl } from '../../utils/resourceUrl';
|
||||
import WatermarkEditor from './WatermarkEditor';
|
||||
import MediaReferencesEditor from './MediaReferencesEditor';
|
||||
|
||||
interface Props {
|
||||
categories: HomeMaterialCategory[];
|
||||
@@ -57,6 +60,12 @@ const defaultConfig: HomeMaterialWatermarkConfig = {
|
||||
textWatermark: defaultTextWatermark,
|
||||
};
|
||||
|
||||
const mediaTypeText: Record<string, string> = {
|
||||
image: '图片',
|
||||
video: '视频',
|
||||
audio: '音频',
|
||||
};
|
||||
|
||||
function normalizeTextConfig(raw: any): HomeMaterialTextWatermarkConfig {
|
||||
return {
|
||||
text: raw?.text || defaultTextWatermark.text,
|
||||
@@ -94,6 +103,43 @@ function isValidConfig(config: HomeMaterialWatermarkConfig): boolean {
|
||||
return !!config.watermarkId;
|
||||
}
|
||||
|
||||
function shortText(value?: string | null, max = 48): string {
|
||||
const text = (value || '').trim();
|
||||
if (!text) return '';
|
||||
return text.length > max ? `${text.slice(0, max)}...` : text;
|
||||
}
|
||||
|
||||
function renderMediaReferences(refs?: HomeMaterialMediaReference[] | null) {
|
||||
const list = refs || [];
|
||||
if (list.length === 0) return <Typography.Text type="secondary">暂无附件</Typography.Text>;
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||
{list.map((ref, index) => {
|
||||
const displayUrl = apiUrl(ref.displayUrl || ref.url);
|
||||
const previewUrl = apiUrl(ref.previewUrl || ref.displayUrl || ref.url);
|
||||
return (
|
||||
<div key={`${ref.url}-${index}`} style={{ border: '1px solid #f0f0f5', borderRadius: 8, padding: 12, display: 'flex', gap: 12 }}>
|
||||
<div style={{ width: 140, minHeight: 80, flexShrink: 0, background: '#f6f7fb', borderRadius: 8, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{ref.type === 'image' && <Image width={140} height={80} style={{ objectFit: 'cover' }} src={previewUrl} />}
|
||||
{ref.type === 'video' && <video src={displayUrl} controls style={{ width: 140, height: 80, objectFit: 'cover', background: '#000' }} />}
|
||||
{ref.type === 'audio' && <audio src={displayUrl} controls style={{ width: 132 }} />}
|
||||
</div>
|
||||
<Space direction="vertical" size={6} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Space wrap>
|
||||
<Tag>{mediaTypeText[ref.type] || ref.type}</Tag>
|
||||
{ref.duration !== undefined && ref.duration !== null && <Tag>{ref.duration}s</Tag>}
|
||||
{ref.role && <Tag>{ref.role}</Tag>}
|
||||
</Space>
|
||||
<Typography.Text strong>{ref.name || '未命名附件'}</Typography.Text>
|
||||
<Typography.Text copyable ellipsis style={{ maxWidth: 620 }}>{ref.url}</Typography.Text>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloadKey }) => {
|
||||
const [items, setItems] = useState<HomeMaterialAsset[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -103,6 +149,10 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
||||
const [regen, setRegen] = useState<HomeMaterialAsset | null>(null);
|
||||
const [regenConfig, setRegenConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
|
||||
const [regenSubmitting, setRegenSubmitting] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<HomeMaterialAsset | null>(null);
|
||||
const [editPrompt, setEditPrompt] = useState('');
|
||||
const [editRefs, setEditRefs] = useState<HomeMaterialMediaReference[]>([]);
|
||||
const [editSubmitting, setEditSubmitting] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
@@ -133,6 +183,12 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
||||
setRegenConfig(normalizeConfig(row.watermarkConfig, row.watermarkId));
|
||||
};
|
||||
|
||||
const openGenerationConfig = (row: HomeMaterialAsset) => {
|
||||
setEditingConfig(row);
|
||||
setEditPrompt(row.generationPrompt || '');
|
||||
setEditRefs(row.mediaReferences || []);
|
||||
};
|
||||
|
||||
const getPreviewImageUrl = (row: HomeMaterialAsset) => {
|
||||
if (row.mediaType === 'image') return apiUrl(row.watermarkedUrl || row.originalUrl);
|
||||
return apiUrl(row.coverUrl || '');
|
||||
@@ -155,6 +211,26 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
||||
});
|
||||
};
|
||||
|
||||
const submitGenerationConfig = async () => {
|
||||
if (!editingConfig || editSubmitting) return;
|
||||
setEditSubmitting(true);
|
||||
try {
|
||||
await updateHomeMaterialAsset(editingConfig.id, {
|
||||
category_id: editingConfig.categoryId,
|
||||
title: editingConfig.title || null,
|
||||
is_active: editingConfig.isActive,
|
||||
sort_order: editingConfig.sortOrder,
|
||||
generation_prompt: editPrompt.trim() || null,
|
||||
media_references: editRefs,
|
||||
});
|
||||
message.success('生成配置已保存');
|
||||
setEditingConfig(null);
|
||||
await load();
|
||||
} finally {
|
||||
setEditSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitRegenerate = async () => {
|
||||
if (!regen || regenSubmitting) return;
|
||||
if (!isValidConfig(regenConfig)) {
|
||||
@@ -229,7 +305,7 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
pagination={{ current: filters.page, pageSize: filters.pageSize, total, onChange: (page, pageSize) => setFilters(f => ({ ...f, page, pageSize })) }}
|
||||
scroll={{ x: 1180 }}
|
||||
scroll={{ x: 1380 }}
|
||||
columns={[
|
||||
{
|
||||
title: '预览',
|
||||
@@ -256,6 +332,18 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
||||
{ title: '行业', dataIndex: 'categoryName' },
|
||||
{ title: '类型', dataIndex: 'mediaType', render: (v: string) => v === 'image' ? '图片' : '视频' },
|
||||
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag color={statusMap[v]?.color}>{statusMap[v]?.text || v}</Tag> },
|
||||
{
|
||||
title: '生成提词',
|
||||
width: 210,
|
||||
render: (_: unknown, row: HomeMaterialAsset) => row.generationPrompt ? (
|
||||
<Tooltip title={row.generationPrompt}><Typography.Text>{shortText(row.generationPrompt)}</Typography.Text></Tooltip>
|
||||
) : <Typography.Text type="secondary">未设置</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '附件',
|
||||
width: 90,
|
||||
render: (_: unknown, row: HomeMaterialAsset) => <Tag color={(row.mediaReferences?.length || 0) > 0 ? 'blue' : 'default'}>{row.mediaReferences?.length || 0}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '水印',
|
||||
render: (_: unknown, row: HomeMaterialAsset) => {
|
||||
@@ -267,7 +355,7 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
||||
{ title: '错误', dataIndex: 'errorMessage', ellipsis: true },
|
||||
{
|
||||
title: '操作',
|
||||
width: 150,
|
||||
width: 170,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: HomeMaterialAsset) => (
|
||||
<Space size={8}>
|
||||
@@ -276,10 +364,12 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'generation_config', icon: <EditOutlined />, label: '编辑生成配置' },
|
||||
{ key: 'regenerate', icon: <ReloadOutlined />, label: '重新生成水印' },
|
||||
{ key: 'delete', icon: <DeleteOutlined />, label: '删除素材', danger: true },
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'generation_config') openGenerationConfig(row);
|
||||
if (key === 'regenerate') openRegenerate(row);
|
||||
if (key === 'delete') confirmDelete(row);
|
||||
},
|
||||
@@ -292,11 +382,21 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Modal title="素材详情" open={!!preview} onCancel={() => setPreview(null)} footer={null} width={900} destroyOnHidden>
|
||||
<Modal title="素材详情" open={!!preview} onCancel={() => setPreview(null)} footer={null} width={960} destroyOnHidden>
|
||||
{preview && (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<div><b>原素材:</b>{preview.originalUrl ? apiUrl(preview.originalUrl) : '-'}</div>
|
||||
<div><b>水印素材:</b>{preview.watermarkedUrl ? apiUrl(preview.watermarkedUrl) : '-'}</div>
|
||||
<div>
|
||||
<b>生成提词:</b>
|
||||
<div style={{ marginTop: 8, padding: 12, borderRadius: 8, background: '#f8fafc', whiteSpace: 'pre-wrap' }}>
|
||||
{preview.generationPrompt || '未设置'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<b>附件 / 参考素材:</b>
|
||||
<div style={{ marginTop: 8 }}>{renderMediaReferences(preview.mediaReferences)}</div>
|
||||
</div>
|
||||
{preview.mediaType === 'image' ? (
|
||||
<Image src={apiUrl(preview.watermarkedUrl || preview.originalUrl)} />
|
||||
) : (
|
||||
@@ -305,6 +405,38 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal
|
||||
title="编辑生成配置"
|
||||
open={!!editingConfig}
|
||||
onCancel={() => !editSubmitting && setEditingConfig(null)}
|
||||
onOk={submitGenerationConfig}
|
||||
confirmLoading={editSubmitting}
|
||||
cancelButtonProps={{ disabled: editSubmitting }}
|
||||
width={920}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={16}>
|
||||
<div>
|
||||
<Typography.Text strong>生成提词</Typography.Text>
|
||||
<Input.TextArea
|
||||
rows={6}
|
||||
maxLength={8000}
|
||||
showCount
|
||||
value={editPrompt}
|
||||
disabled={editSubmitting}
|
||||
placeholder="请输入用于生成该素材的提示词"
|
||||
onChange={(e) => setEditPrompt(e.target.value)}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong>附件 / 参考素材</Typography.Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<MediaReferencesEditor value={editRefs} onChange={setEditRefs} disabled={editSubmitting} />
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
<Modal
|
||||
title="重新生成水印"
|
||||
open={!!regen}
|
||||
|
||||
@@ -2,8 +2,9 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Form, Input, InputNumber, Modal, Select, Switch, Upload, Button, message } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import { uploadHomeMaterialAsset } from '../../api';
|
||||
import type { HomeMaterialCategory, HomeMaterialMediaType, HomeMaterialWatermark, HomeMaterialWatermarkConfig } from '../../types';
|
||||
import type { HomeMaterialCategory, HomeMaterialMediaReference, HomeMaterialMediaType, HomeMaterialWatermark, HomeMaterialWatermarkConfig } from '../../types';
|
||||
import WatermarkEditor from './WatermarkEditor';
|
||||
import MediaReferencesEditor from './MediaReferencesEditor';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -50,6 +51,7 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
|
||||
const [config, setConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [mediaObjectUrl, setMediaObjectUrl] = useState<string>('');
|
||||
const [mediaReferences, setMediaReferences] = useState<HomeMaterialMediaReference[]>([]);
|
||||
|
||||
const resetState = () => {
|
||||
setFile(null);
|
||||
@@ -57,6 +59,7 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
|
||||
setMediaType('image');
|
||||
setMediaObjectUrl('');
|
||||
setSubmitting(false);
|
||||
setMediaReferences([]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -64,7 +67,7 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
|
||||
resetState();
|
||||
const defaultWatermark = watermarks.find(w => w.isDefault) || watermarks[0];
|
||||
setConfig({ ...defaultConfig, watermarkId: defaultWatermark?.id || null });
|
||||
form.setFieldsValue({ media_type: 'image', is_active: true, sort_order: 0, title: undefined });
|
||||
form.setFieldsValue({ media_type: 'image', is_active: true, sort_order: 0, title: undefined, generation_prompt: undefined });
|
||||
}
|
||||
}, [open, watermarks, form]);
|
||||
|
||||
@@ -136,6 +139,8 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
|
||||
file,
|
||||
mediaType,
|
||||
title: values.title?.trim() || null,
|
||||
generationPrompt: values.generation_prompt?.trim() || null,
|
||||
mediaReferences,
|
||||
watermarkType: config.watermarkType,
|
||||
watermarkId: config.watermarkType === 'image' ? config.watermarkId : null,
|
||||
opacityLevel: config.opacityLevel,
|
||||
@@ -197,6 +202,12 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="素材标题" extra="选填;不填时后台显示“未命名素材”,不会再自动使用文件名作为标题。"><Input disabled={submitting} /></Form.Item>
|
||||
<Form.Item name="generation_prompt" label="生成提词" extra="选填;会随前台素材接口返回。">
|
||||
<Input.TextArea rows={5} maxLength={8000} showCount disabled={submitting} placeholder="请输入用于生成该素材的提示词" />
|
||||
</Form.Item>
|
||||
<Form.Item label="附件 / 参考素材">
|
||||
<MediaReferencesEditor value={mediaReferences} onChange={setMediaReferences} disabled={submitting} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序"><InputNumber min={0} style={{ width: '100%' }} disabled={submitting} /></Form.Item>
|
||||
<Form.Item name="is_active" label="前台展示" valuePropName="checked"><Switch disabled={submitting} /></Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Button, Card, Image, Input, InputNumber, Space, Tag, Upload, message } from 'antd';
|
||||
import { DeleteOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import { uploadAdminFile } from '../../api';
|
||||
import type { AdminUploadResourceType, HomeMaterialMediaReference } from '../../types';
|
||||
import { apiUrl } from '../../utils/resourceUrl';
|
||||
|
||||
interface Props {
|
||||
value?: HomeMaterialMediaReference[] | null;
|
||||
onChange: (value: HomeMaterialMediaReference[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const mediaTypeText: Record<string, string> = {
|
||||
image: '图片',
|
||||
video: '视频',
|
||||
audio: '音频',
|
||||
};
|
||||
|
||||
function detectResourceType(file: File): AdminUploadResourceType | null {
|
||||
if (file.type.startsWith('image/')) return 'image';
|
||||
if (file.type.startsWith('video/')) return 'video';
|
||||
if (file.type.startsWith('audio/')) return 'audio';
|
||||
const name = file.name.toLowerCase();
|
||||
if (/\.(jpg|jpeg|png|webp|gif)$/.test(name)) return 'image';
|
||||
if (/\.(mp4|mov|m4v|webm)$/.test(name)) return 'video';
|
||||
if (/\.(mp3|wav|m4a|aac)$/.test(name)) return 'audio';
|
||||
return null;
|
||||
}
|
||||
|
||||
function readDuration(file: File, resourceType: AdminUploadResourceType): Promise<number | null> {
|
||||
if (resourceType !== 'video' && resourceType !== 'audio') return Promise.resolve(null);
|
||||
return new Promise(resolve => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const el = document.createElement(resourceType === 'video' ? 'video' : 'audio');
|
||||
el.preload = 'metadata';
|
||||
el.onloadedmetadata = () => {
|
||||
const duration = Number.isFinite(el.duration) ? Number(el.duration.toFixed(3)) : null;
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(duration);
|
||||
};
|
||||
el.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(null);
|
||||
};
|
||||
el.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
const MediaReferencesEditor: React.FC<Props> = ({ value, onChange, disabled }) => {
|
||||
const refs = useMemo(() => value || [], [value]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const updateItem = (index: number, patch: Partial<HomeMaterialMediaReference>) => {
|
||||
onChange(refs.map((item, idx) => idx === index ? { ...item, ...patch } : item));
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
onChange(refs.filter((_, idx) => idx !== index));
|
||||
};
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
const resourceType = detectResourceType(file);
|
||||
if (!resourceType || !['image', 'video', 'audio'].includes(resourceType)) {
|
||||
message.warning('附件仅支持图片、视频、音频');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const durationSeconds = await readDuration(file, resourceType);
|
||||
const res = await uploadAdminFile(file, {
|
||||
scene: 'home_material_reference',
|
||||
resourceType,
|
||||
durationSeconds,
|
||||
});
|
||||
const ref = res.mediaReference;
|
||||
if (!ref || !['image', 'video', 'audio'].includes(ref.type)) {
|
||||
throw new Error('上传返回附件格式异常');
|
||||
}
|
||||
onChange([
|
||||
...refs,
|
||||
{
|
||||
url: ref.url,
|
||||
type: ref.type as 'image' | 'video' | 'audio',
|
||||
name: ref.name || file.name,
|
||||
duration: ref.duration ?? durationSeconds,
|
||||
source: ref.source,
|
||||
uploadResourceId: ref.uploadResourceId,
|
||||
displayUrl: ref.displayUrl || ref.url,
|
||||
previewUrl: ref.previewUrl || ref.displayUrl || ref.url,
|
||||
role: ref.role || `reference_${ref.type}`,
|
||||
},
|
||||
]);
|
||||
message.success('附件上传成功');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '附件上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
return Upload.LIST_IGNORE;
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||
<Upload beforeUpload={(file) => uploadFile(file as File)} showUploadList={false} disabled={disabled || uploading} accept="image/*,video/*,audio/*">
|
||||
<Button icon={<UploadOutlined />} loading={uploading} disabled={disabled || uploading}>上传附件</Button>
|
||||
</Upload>
|
||||
{refs.length === 0 ? (
|
||||
<div style={{ color: '#999', fontSize: 13 }}>暂无附件。可上传图片、视频、音频,保存后会随前台素材接口返回。</div>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={10}>
|
||||
{refs.map((ref, index) => {
|
||||
const previewUrl = apiUrl(ref.previewUrl || ref.displayUrl || ref.url);
|
||||
const displayUrl = apiUrl(ref.displayUrl || ref.url);
|
||||
return (
|
||||
<Card key={`${ref.url}-${index}`} size="small" bodyStyle={{ padding: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
<div style={{ width: 120, minHeight: 72, flexShrink: 0, borderRadius: 8, overflow: 'hidden', background: '#f6f7fb', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{ref.type === 'image' && <Image width={120} height={72} style={{ objectFit: 'cover' }} src={previewUrl} />}
|
||||
{ref.type === 'video' && <video src={displayUrl} style={{ width: 120, height: 72, objectFit: 'cover', background: '#000' }} controls />}
|
||||
{ref.type === 'audio' && <audio src={displayUrl} controls style={{ width: 112 }} />}
|
||||
</div>
|
||||
<Space direction="vertical" style={{ flex: 1, minWidth: 0 }} size={8}>
|
||||
<Space wrap>
|
||||
<Tag color={ref.type === 'image' ? 'blue' : ref.type === 'video' ? 'purple' : 'green'}>{mediaTypeText[ref.type] || ref.type}</Tag>
|
||||
{ref.uploadResourceId && <Tag>资源ID:{ref.uploadResourceId}</Tag>}
|
||||
</Space>
|
||||
<Input
|
||||
size="small"
|
||||
value={ref.name || ''}
|
||||
placeholder="附件名称"
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateItem(index, { name: e.target.value || null })}
|
||||
/>
|
||||
<Input
|
||||
size="small"
|
||||
value={ref.role || ''}
|
||||
placeholder="附件角色,如 reference_image"
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateItem(index, { role: e.target.value || null })}
|
||||
/>
|
||||
{(ref.type === 'video' || ref.type === 'audio') && (
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
precision={3}
|
||||
style={{ width: 180 }}
|
||||
value={ref.duration ?? null}
|
||||
placeholder="时长(秒)"
|
||||
disabled={disabled}
|
||||
onChange={(v) => updateItem(index, { duration: v === null ? null : Number(v) })}
|
||||
/>
|
||||
)}
|
||||
<Input size="small" value={ref.url} disabled />
|
||||
</Space>
|
||||
<Button danger size="small" icon={<DeleteOutlined />} disabled={disabled} onClick={() => removeItem(index)}>删除</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaReferencesEditor;
|
||||
@@ -176,6 +176,8 @@ export interface AdminTeamPayload {
|
||||
description?: string | null;
|
||||
status: 'active' | 'disabled';
|
||||
sort_order: number;
|
||||
generation_prompt?: string | null;
|
||||
media_references?: HomeMaterialMediaReference[] | null;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
@@ -877,6 +879,53 @@ export interface AdminCreditRecordQueryParams {
|
||||
|
||||
// ── 首页素材行业装修 ──────────────────────────────────────
|
||||
|
||||
|
||||
export type AdminUploadScene =
|
||||
| 'system_logo'
|
||||
| 'system_pdf'
|
||||
| 'open_type_thumb'
|
||||
| 'home_material_reference'
|
||||
| 'admin_common';
|
||||
|
||||
export type AdminUploadResourceType = 'image' | 'video' | 'audio' | 'pdf' | 'file';
|
||||
|
||||
export interface HomeMaterialMediaReference {
|
||||
url: string;
|
||||
type: 'image' | 'video' | 'audio';
|
||||
name?: string | null;
|
||||
duration?: number | null;
|
||||
source?: string | null;
|
||||
uploadResourceId?: string | null;
|
||||
displayUrl?: string | null;
|
||||
previewUrl?: string | null;
|
||||
role?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminUploadMediaReference {
|
||||
url: string;
|
||||
type: AdminUploadResourceType;
|
||||
name?: string | null;
|
||||
duration?: number | null;
|
||||
source: string;
|
||||
uploadResourceId?: string | null;
|
||||
displayUrl?: string | null;
|
||||
previewUrl?: string | null;
|
||||
role?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminUploadFileResult {
|
||||
resourceId: string;
|
||||
scene: AdminUploadScene;
|
||||
module: string;
|
||||
resourceType: AdminUploadResourceType;
|
||||
url: string;
|
||||
fileName: string;
|
||||
originalFileName?: string | null;
|
||||
fileSizeBytes: number;
|
||||
durationSeconds?: number | null;
|
||||
mediaReference?: AdminUploadMediaReference | null;
|
||||
}
|
||||
|
||||
export type HomeMaterialMediaType = 'image' | 'video';
|
||||
export type HomeMaterialAssetStatus = 'draft' | 'processing' | 'success' | 'failed';
|
||||
export type HomeMaterialWatermarkType = 'image' | 'repeated_text';
|
||||
@@ -998,6 +1047,8 @@ export interface HomeMaterialAsset {
|
||||
watermarkId?: string | null;
|
||||
watermarkName?: string | null;
|
||||
watermarkConfig?: HomeMaterialWatermarkConfig | Record<string, unknown> | null;
|
||||
generationPrompt?: string | null;
|
||||
mediaReferences?: HomeMaterialMediaReference[] | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
durationSeconds?: number | string | null;
|
||||
@@ -1016,6 +1067,8 @@ export interface HomeMaterialAssetUpdatePayload {
|
||||
title?: string | null;
|
||||
is_active: boolean;
|
||||
sort_order: number;
|
||||
generation_prompt?: string | null;
|
||||
media_references?: HomeMaterialMediaReference[] | null;
|
||||
}
|
||||
|
||||
export interface HomeMaterialAssetStatusOut {
|
||||
@@ -1038,6 +1091,8 @@ export interface HomeMaterialUploadResult {
|
||||
watermarkedUrl?: string | null;
|
||||
coverUrl?: string | null;
|
||||
watermarkConfig?: HomeMaterialWatermarkConfig | Record<string, unknown> | null;
|
||||
generationPrompt?: string | null;
|
||||
mediaReferences?: HomeMaterialMediaReference[] | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -1074,6 +1129,8 @@ export interface HomeMaterialUploadAssetParams {
|
||||
file: File;
|
||||
mediaType: HomeMaterialMediaType;
|
||||
title?: string | null;
|
||||
generationPrompt?: string | null;
|
||||
mediaReferences?: HomeMaterialMediaReference[] | null;
|
||||
watermarkType: HomeMaterialWatermarkType;
|
||||
watermarkId?: string | null;
|
||||
watermarkFile?: File | null;
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/resourceurl.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
|
||||
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/resourceurl.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
|
||||
@@ -0,0 +1,37 @@
|
||||
"""2026070901_add_credits_ratio_增加上传图片积分规则
|
||||
|
||||
Revision ID: 2026070901
|
||||
Revises: 6c1aaf036f43
|
||||
Create Date: 2026-07-01 00:00:00.000000
|
||||
|
||||
该文件包含 2026-07-01 的数据库迁移内容:
|
||||
1. 积分规则表增加传入视频计费字段(input_video_ratio, input_video_base_credits, input_video_per_second_credits)
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '2026070901'
|
||||
down_revision: Union[str, None] = '6c1aaf036f43'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ========================================
|
||||
# 2026-07-01 - 积分规则表增加传入图片计费字段
|
||||
# ========================================
|
||||
op.add_column('credit_ratios', sa.Column('input_image_ratio', sa.Float(), server_default='1.0', nullable=False))
|
||||
op.add_column('credit_ratios', sa.Column('input_image_base_credits', sa.Float(), server_default='0.0', nullable=False))
|
||||
op.add_column('credit_ratios', sa.Column('input_image_per_image_credits', sa.Float(), server_default='0.5', nullable=False))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ========================================
|
||||
# 2026-07-01 - 积分规则表增加传入图片计费字段(回滚)
|
||||
# ========================================
|
||||
op.drop_column('credit_ratios', 'input_image_per_image_credits')
|
||||
op.drop_column('credit_ratios', 'input_image_base_credits')
|
||||
op.drop_column('credit_ratios', 'input_image_ratio')
|
||||
@@ -0,0 +1,51 @@
|
||||
"""2026070902_add_generated_date_expression_indexes
|
||||
|
||||
Revision ID: 2026070902
|
||||
Revises: 2026070901
|
||||
Create Date: 2026-07-09 00:00:00.000000
|
||||
|
||||
该文件包含 2026-07-09 的数据库迁移内容:
|
||||
1. chat_generation_tasks 表增加 generated_at 日期表达式索引,优化按日期分组/筛选
|
||||
2. generation_records 表增加 generated_at 日期表达式索引,优化按日期分组/筛选
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '2026070902'
|
||||
down_revision: Union[str, None] = '2026070901'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ========================================
|
||||
# 2026-07-09 - 生成记录表增加日期表达式索引
|
||||
# ========================================
|
||||
# 使用 CAST(timezone('utc', generated_at) AS date) 而不是 date(generated_at):
|
||||
# 1) 当 generated_at 为 timestamptz 时,date() 的结果依赖会话时区,属于 STABLE 函数,
|
||||
# 无法用于索引表达式。显式指定 UTC 时区后转为 date 是纯计算,属于 IMMUTABLE。
|
||||
# 2) 必须用 CAST(... AS date) 而不能用 ::date::: 的优先级高于函数调用,
|
||||
# PG 会把 func(x)::date 解析为 func(x::date),导致语法错误。
|
||||
op.create_index(
|
||||
'idx_chat_generation_tasks_generated_date',
|
||||
'chat_generation_tasks',
|
||||
[sa.text("CAST(timezone('utc', generated_at) AS date)")],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
'idx_generation_records_generated_date',
|
||||
'generation_records',
|
||||
[sa.text("CAST(timezone('utc', generated_at) AS date)")],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ========================================
|
||||
# 2026-07-09 - 生成记录表增加日期表达式索引(回滚)
|
||||
# ========================================
|
||||
op.drop_index('idx_generation_records_generated_date', table_name='generation_records')
|
||||
op.drop_index('idx_chat_generation_tasks_generated_date', table_name='chat_generation_tasks')
|
||||
@@ -0,0 +1,31 @@
|
||||
"""add home material generation config
|
||||
|
||||
Revision ID: 6c1aaf036f43
|
||||
Revises: 1475d11b1d74
|
||||
Create Date: 2026-07-09 10:28:05.067550
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '6c1aaf036f43'
|
||||
down_revision: Union[str, None] = '1475d11b1d74'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('home_material_assets', sa.Column('generation_prompt', sa.Text(), nullable=True, comment='生成提词'))
|
||||
op.add_column('home_material_assets', sa.Column('media_references_json', sa.Text(), nullable=True, comment='附件/参考素材JSON字符串'))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('home_material_assets', 'media_references_json')
|
||||
op.drop_column('home_material_assets', 'generation_prompt')
|
||||
# ### end Alembic commands ###
|
||||
@@ -7,6 +7,7 @@ from app.api.admin.home_material import router as home_material_router
|
||||
from app.api.admin.private_portrait import router as private_portrait_router
|
||||
from app.api.admin.recharge_package import router as recharge_package_router
|
||||
from app.api.admin.menu_config import router as menu_config_router
|
||||
from app.api.admin.upload import router as admin_upload_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(video_prompt_schema_config_router)
|
||||
@@ -16,3 +17,4 @@ router.include_router(home_material_router)
|
||||
router.include_router(private_portrait_router)
|
||||
router.include_router(recharge_package_router)
|
||||
router.include_router(menu_config_router)
|
||||
router.include_router(admin_upload_router)
|
||||
|
||||
@@ -336,6 +336,8 @@ async def upload_home_material_asset(
|
||||
file: UploadFile = File(..., description="图片或视频素材文件。"),
|
||||
media_type: HomeMaterialMediaType = Form(..., description="素材类型:image图片,video视频。"),
|
||||
title: str | None = Form(None, description="素材标题。为空时不再自动回填文件名。"),
|
||||
generation_prompt: str | None = Form(None, description="生成提词,可为空。"),
|
||||
media_references_json: str | None = Form(None, description="附件/参考素材JSON字符串,格式参考 generation_ai.create_task 的 media_references。"),
|
||||
watermark_type: HomeMaterialWatermarkType = Form(HomeMaterialWatermarkType.IMAGE, description="水印类型:image 图片水印;repeated_text 重复文字水印。"),
|
||||
watermark_id: str | None = Form(None, description="图片水印ID,可选。watermark_type=image 时使用。"),
|
||||
watermark_file: UploadFile | None = File(None, description="临时图片水印,可选。watermark_type=image 时使用。"),
|
||||
@@ -395,6 +397,8 @@ async def upload_home_material_asset(
|
||||
file=file,
|
||||
media_type=media_type,
|
||||
title=title,
|
||||
generation_prompt=generation_prompt,
|
||||
media_references_json=media_references_json,
|
||||
watermark_id=watermark_id,
|
||||
watermark_file=watermark_file,
|
||||
watermark_config=config,
|
||||
@@ -452,7 +456,7 @@ async def get_home_material_asset_status(
|
||||
"/assets/{asset_id}",
|
||||
response_model=HomeMaterialAssetOut,
|
||||
summary="修改首页素材展示信息",
|
||||
description="只修改行业、标题、启用状态、排序,不重新生成水印。",
|
||||
description="修改行业、标题、启用状态、排序、生成提词和附件JSON,不重新生成水印。",
|
||||
)
|
||||
async def update_home_material_asset(
|
||||
req: HomeMaterialAssetUpdate,
|
||||
@@ -465,7 +469,7 @@ async def update_home_material_asset(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
HomeMaterialOperationEnum.ASSET_UPDATE.value,
|
||||
(HomeMaterialOperationEnum.ASSET_GENERATION_CONFIG_UPDATE.value if (before.get("generation_prompt") != after.get("generation_prompt") or before.get("media_references_count") != after.get("media_references_count")) else HomeMaterialOperationEnum.ASSET_UPDATE.value),
|
||||
"PUT",
|
||||
f"/admin/home-material/assets/{asset_id}",
|
||||
detail=_detail(before=before, after=after),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_admin_user, get_db
|
||||
from app.enums.admin_upload import AdminUploadResourceTypeEnum, AdminUploadSceneEnum
|
||||
from app.models.user import User
|
||||
from app.schemas.admin_upload import AdminUploadFileOut
|
||||
from app.services.admin_upload import admin_upload_service
|
||||
|
||||
router = APIRouter(prefix="/admin/uploads", tags=["admin-upload"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/files",
|
||||
response_model=AdminUploadFileOut,
|
||||
summary="管理后台全局文件上传",
|
||||
description=(
|
||||
"管理后台纯文件上传统一入口。通过 scene 区分业务场景并落到独立目录;"
|
||||
"首页素材附件使用 scene=home_material_reference,系统Logo使用 system_logo,PDF使用 system_pdf,开户缩略图使用 open_type_thumb。"
|
||||
),
|
||||
)
|
||||
async def upload_admin_file(
|
||||
file: UploadFile = File(..., description="上传文件。"),
|
||||
scene: AdminUploadSceneEnum = Form(..., description="上传场景。"),
|
||||
resource_type: AdminUploadResourceTypeEnum = Form(..., description="资源类型:image/video/audio/pdf/file。"),
|
||||
duration_seconds: float | None = Form(None, ge=0, description="音视频时长,单位秒。"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await admin_upload_service.upload_file(
|
||||
db,
|
||||
file=file,
|
||||
scene=scene,
|
||||
resource_type=resource_type,
|
||||
duration_seconds=duration_seconds,
|
||||
admin=admin,
|
||||
)
|
||||
await db.commit()
|
||||
return result
|
||||
@@ -1383,6 +1383,7 @@ async def create_credit_ratio(
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
return ratio
|
||||
|
||||
|
||||
@@ -1422,6 +1423,7 @@ async def update_credit_ratio(
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
return ratio
|
||||
|
||||
|
||||
@@ -1455,6 +1457,7 @@ async def delete_credit_ratio(
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
|
||||
@@ -358,6 +358,11 @@ async def list_history_grouped_days(
|
||||
),
|
||||
examples=["shot_replicate"],
|
||||
),
|
||||
keyword: str | None = Query(
|
||||
None,
|
||||
description="提示词搜索关键词,模糊匹配 original_prompt 字段",
|
||||
examples=["猫咪"],
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -368,6 +373,7 @@ async def list_history_grouped_days(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
history_source=history_source,
|
||||
keyword=keyword,
|
||||
)
|
||||
|
||||
|
||||
@@ -481,6 +487,11 @@ async def list_history_day_items(
|
||||
),
|
||||
examples=["hot_opening_replicate"],
|
||||
),
|
||||
keyword: str | None = Query(
|
||||
None,
|
||||
description="提示词搜索关键词,模糊匹配 original_prompt 字段",
|
||||
examples=["猫咪"],
|
||||
),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -492,6 +503,7 @@ async def list_history_day_items(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
history_source=history_source,
|
||||
keyword=keyword,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlencode, unquote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -18,6 +18,7 @@ from app.enums.private_portrait import (
|
||||
)
|
||||
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject
|
||||
from app.models.user import User
|
||||
from app.enums.upload_resource import UploadResourceTypeEnum
|
||||
from app.schemas.private_portrait import (
|
||||
PrivatePortraitAssetCreate,
|
||||
PrivatePortraitAssetListOut,
|
||||
@@ -31,6 +32,7 @@ from app.schemas.private_portrait import (
|
||||
PrivatePortraitProjectOut,
|
||||
PrivatePortraitProjectUpdate,
|
||||
PrivatePortraitSelectableAssetListOut,
|
||||
PrivatePortraitUploadOut,
|
||||
PrivatePortraitValidateSessionCreate,
|
||||
PrivatePortraitValidateSessionOut,
|
||||
build_private_portrait_enum_meta,
|
||||
@@ -55,6 +57,8 @@ from app.services.private_portrait.project_service import (
|
||||
refresh_project_counters,
|
||||
soft_delete_project,
|
||||
)
|
||||
from app.services.private_portrait.upload_service import upload_private_portrait_asset_file
|
||||
from app.services.upload_resource import cleanup_upload_resource_files_after_commit
|
||||
from app.services.private_portrait.real_person.service import (
|
||||
create_real_person_asset,
|
||||
create_real_person_project,
|
||||
@@ -124,6 +128,62 @@ async def get_private_portrait_enum_meta():
|
||||
return build_private_portrait_enum_meta()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/private-portrait/uploads/image",
|
||||
response_model=PrivatePortraitUploadOut,
|
||||
summary="上传真人图片素材",
|
||||
description="上传真人图片素材并写入 UploadResource,module=private_portrait_real。创建素材时需回传 resource_id 到 upload_resource_id。",
|
||||
)
|
||||
async def upload_private_portrait_image(file: UploadFile = File(...), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
out = await upload_private_portrait_asset_file(
|
||||
db,
|
||||
file=file,
|
||||
current_user=current_user,
|
||||
library_type=PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
resource_type=UploadResourceTypeEnum.IMAGE.value,
|
||||
)
|
||||
await db.commit()
|
||||
return out
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"上传真人图片素材失败: {exc}")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/private-portrait/uploads/video",
|
||||
response_model=PrivatePortraitUploadOut,
|
||||
summary="上传真人视频素材",
|
||||
description="上传真人视频素材并写入 UploadResource,module=private_portrait_real。创建素材时需回传 resource_id 到 upload_resource_id。",
|
||||
)
|
||||
async def upload_private_portrait_video(
|
||||
file: UploadFile = File(...),
|
||||
duration_seconds: float | None = Query(None, description="客户端解析的视频秒数,服务端会写入 UploadResource 并在创建素材时回填"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
out = await upload_private_portrait_asset_file(
|
||||
db,
|
||||
file=file,
|
||||
current_user=current_user,
|
||||
library_type=PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
resource_type=UploadResourceTypeEnum.VIDEO.value,
|
||||
duration_seconds=duration_seconds,
|
||||
)
|
||||
await db.commit()
|
||||
return out
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"上传真人视频素材失败: {exc}")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/private-portrait/projects",
|
||||
response_model=PrivatePortraitProjectCreateWithValidateOut,
|
||||
@@ -196,6 +256,7 @@ async def update_private_portrait_project(project_id: str, payload: PrivatePortr
|
||||
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, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
project_id_snapshot = project.id
|
||||
pending_upload_resource_ids = list(getattr(project, "_pending_upload_resource_ids", []) or [])
|
||||
await db.commit()
|
||||
try:
|
||||
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_project_remote
|
||||
@@ -204,6 +265,21 @@ async def delete_private_portrait_project(project_id: str, current_user: User =
|
||||
_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)
|
||||
if pending_upload_resource_ids:
|
||||
try:
|
||||
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_upload_resource_ids)
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
log_operation_error(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_UPLOAD_RELEASE_FAILED.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=current_user.id,
|
||||
project_id=project_id_snapshot,
|
||||
exc=exc,
|
||||
detail={"resource_ids": pending_upload_resource_ids},
|
||||
)
|
||||
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
|
||||
|
||||
|
||||
@@ -337,6 +413,7 @@ async def delete_private_portrait_asset(asset_id: str, current_user: User = Depe
|
||||
asset = await soft_delete_asset(db, user_id=current_user.id, asset_id=asset_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
asset_id_snapshot = asset.id
|
||||
project_id_snapshot = asset.project_id
|
||||
pending_upload_resource_ids = list(getattr(asset, "_pending_upload_resource_ids", []) or [])
|
||||
await db.commit()
|
||||
try:
|
||||
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_asset_remote
|
||||
@@ -345,6 +422,22 @@ async def delete_private_portrait_asset(asset_id: str, current_user: User = Depe
|
||||
_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)
|
||||
if pending_upload_resource_ids:
|
||||
try:
|
||||
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_upload_resource_ids)
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
log_operation_error(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_UPLOAD_RELEASE_FAILED.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=current_user.id,
|
||||
project_id=project_id_snapshot,
|
||||
asset_id=asset_id_snapshot,
|
||||
exc=exc,
|
||||
detail={"resource_ids": pending_upload_resource_ids},
|
||||
)
|
||||
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.enums.private_portrait import (
|
||||
)
|
||||
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject
|
||||
from app.models.user import User
|
||||
from app.enums.upload_resource import UploadResourceTypeEnum
|
||||
from app.schemas.private_portrait import (
|
||||
PrivatePortraitAssetCreate,
|
||||
PrivatePortraitAssetListOut,
|
||||
@@ -26,6 +27,7 @@ from app.schemas.private_portrait import (
|
||||
PrivatePortraitProjectOut,
|
||||
PrivatePortraitProjectUpdate,
|
||||
PrivatePortraitSelectableAssetListOut,
|
||||
PrivatePortraitUploadOut,
|
||||
PrivatePortraitVirtualProjectCreate,
|
||||
build_private_portrait_enum_meta,
|
||||
)
|
||||
@@ -46,6 +48,8 @@ from app.services.private_portrait.project_service import (
|
||||
refresh_project_counters,
|
||||
soft_delete_project,
|
||||
)
|
||||
from app.services.private_portrait.upload_service import upload_private_portrait_asset_file
|
||||
from app.services.upload_resource import cleanup_upload_resource_files_after_commit
|
||||
from app.services.private_portrait.virtual.service import create_virtual_asset, create_virtual_project, update_virtual_project
|
||||
|
||||
router = APIRouter(tags=["私域虚拟人像素材库"])
|
||||
@@ -103,6 +107,62 @@ async def get_virtual_private_portrait_enum_meta():
|
||||
return build_private_portrait_enum_meta()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/private-portrait/virtual/uploads/image",
|
||||
response_model=PrivatePortraitUploadOut,
|
||||
summary="上传虚拟图片素材",
|
||||
description="上传虚拟图片素材并写入 UploadResource,module=private_portrait_virtual。创建素材时需回传 resource_id 到 upload_resource_id。",
|
||||
)
|
||||
async def upload_private_portrait_virtual_image(file: UploadFile = File(...), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
out = await upload_private_portrait_asset_file(
|
||||
db,
|
||||
file=file,
|
||||
current_user=current_user,
|
||||
library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value,
|
||||
resource_type=UploadResourceTypeEnum.IMAGE.value,
|
||||
)
|
||||
await db.commit()
|
||||
return out
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"上传虚拟图片素材失败: {exc}")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/private-portrait/virtual/uploads/video",
|
||||
response_model=PrivatePortraitUploadOut,
|
||||
summary="上传虚拟视频素材",
|
||||
description="上传虚拟视频素材并写入 UploadResource,module=private_portrait_virtual。创建素材时需回传 resource_id 到 upload_resource_id。",
|
||||
)
|
||||
async def upload_private_portrait_virtual_video(
|
||||
file: UploadFile = File(...),
|
||||
duration_seconds: float | None = Query(None, description="客户端解析的视频秒数,服务端会写入 UploadResource 并在创建素材时回填"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
out = await upload_private_portrait_asset_file(
|
||||
db,
|
||||
file=file,
|
||||
current_user=current_user,
|
||||
library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value,
|
||||
resource_type=UploadResourceTypeEnum.VIDEO.value,
|
||||
duration_seconds=duration_seconds,
|
||||
)
|
||||
await db.commit()
|
||||
return out
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"上传虚拟视频素材失败: {exc}")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/private-portrait/virtual-projects",
|
||||
response_model=PrivatePortraitProjectOut,
|
||||
@@ -170,6 +230,7 @@ async def update_private_portrait_virtual_project(project_id: str, payload: Priv
|
||||
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
|
||||
pending_upload_resource_ids = list(getattr(project, "_pending_upload_resource_ids", []) or [])
|
||||
await db.commit()
|
||||
try:
|
||||
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_project_remote
|
||||
@@ -178,6 +239,21 @@ async def delete_private_portrait_virtual_project(project_id: str, current_user:
|
||||
_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)
|
||||
if pending_upload_resource_ids:
|
||||
try:
|
||||
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_upload_resource_ids)
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
log_operation_error(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_UPLOAD_RELEASE_FAILED.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=current_user.id,
|
||||
project_id=project_id_snapshot,
|
||||
exc=exc,
|
||||
detail={"resource_ids": pending_upload_resource_ids},
|
||||
)
|
||||
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
|
||||
|
||||
|
||||
@@ -266,6 +342,7 @@ async def delete_private_portrait_virtual_asset(asset_id: str, current_user: Use
|
||||
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
|
||||
pending_upload_resource_ids = list(getattr(asset, "_pending_upload_resource_ids", []) or [])
|
||||
await db.commit()
|
||||
try:
|
||||
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_asset_remote
|
||||
@@ -274,6 +351,22 @@ async def delete_private_portrait_virtual_asset(asset_id: str, current_user: Use
|
||||
_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)
|
||||
if pending_upload_resource_ids:
|
||||
try:
|
||||
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_upload_resource_ids)
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
log_operation_error(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_UPLOAD_RELEASE_FAILED.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=current_user.id,
|
||||
project_id=project_id_snapshot,
|
||||
asset_id=asset_id_snapshot,
|
||||
exc=exc,
|
||||
detail={"resource_ids": pending_upload_resource_ids},
|
||||
)
|
||||
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query,
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.enums.shot_replicate import (
|
||||
@@ -30,6 +31,8 @@ from app.schemas.shot_replicate import (
|
||||
ShotReplicateImagePromptUpdateRequest,
|
||||
ShotReanalyzeOut,
|
||||
ShotReanalyzeRequest,
|
||||
ShotSegmentSplitRetryOut,
|
||||
ShotSplitRetryRequest,
|
||||
ShotReplicateMaterialUpdateRequest,
|
||||
ShotReplicateSpecOut,
|
||||
ShotReplicateTaskDetailOut,
|
||||
@@ -71,6 +74,7 @@ from app.services.shot_replicate_taskset_service import (
|
||||
list_task_sets,
|
||||
prepare_reanalyze_segment,
|
||||
prepare_reanalyze_task_set,
|
||||
prepare_retry_split_segment,
|
||||
segment_detail,
|
||||
task_set_detail,
|
||||
)
|
||||
@@ -685,6 +689,83 @@ async def reanalyze_segment(
|
||||
return out
|
||||
|
||||
|
||||
@router.post(
|
||||
"/segments/{segment_id}/retry-split",
|
||||
response_model=ShotSegmentSplitRetryOut,
|
||||
summary="重试拆镜片段视频切片",
|
||||
description="用于处理 ShotReplicateSegment 视频切片失败;重置 split_status 后复用现有 split_one_segment Celery 任务重新切割。",
|
||||
)
|
||||
async def retry_split_segment(
|
||||
segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"),
|
||||
req: ShotSplitRetryRequest = Body(default_factory=ShotSplitRetryRequest, description="切片失败重试参数"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ensure_celery_enabled(current_user=current_user, step_id=segment_id)
|
||||
try:
|
||||
out = await prepare_retry_split_segment(
|
||||
db,
|
||||
current_user=current_user,
|
||||
segment_id=segment_id,
|
||||
force=req.force,
|
||||
reason=req.reason,
|
||||
)
|
||||
task_set_id = out.task_set_id
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_error(
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_SPLIT_RETRY_DISPATCH_FAILED.value,
|
||||
current_user=current_user,
|
||||
step_id=segment_id,
|
||||
message=f"切片失败重试状态重置失败: {exc}",
|
||||
detail={"segment_id": segment_id, "request": req.model_dump()},
|
||||
exc=exc,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"切片失败重试状态重置失败: {exc}")
|
||||
|
||||
try:
|
||||
from app.tasks.shot_replicate_tasks import split_one_segment
|
||||
|
||||
await register_shot_split_task(segment_id, task_set_id=task_set_id)
|
||||
split_one_segment.apply_async(
|
||||
args=[segment_id],
|
||||
queue="gen_result_download",
|
||||
countdown=0,
|
||||
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
|
||||
)
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_SPLIT_RETRY_SUBMITTED.value,
|
||||
project_id=task_set_id,
|
||||
step_id=segment_id,
|
||||
user_id=_safe_user_id(current_user),
|
||||
message="拆镜片段切片重试任务已投递",
|
||||
detail={
|
||||
"segment_id": segment_id,
|
||||
"task_set_id": task_set_id,
|
||||
"task": "split_one_segment",
|
||||
"queue": "gen_result_download",
|
||||
"request": req.model_dump(),
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
_log_api_error(
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_SPLIT_RETRY_DISPATCH_FAILED.value,
|
||||
current_user=current_user,
|
||||
project_id=task_set_id,
|
||||
step_id=segment_id,
|
||||
message=f"拆镜片段切片重试任务投递失败,等待恢复任务兜底: {exc}",
|
||||
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "split_one_segment"},
|
||||
exc=exc,
|
||||
)
|
||||
out.message = "切片状态已重置,但 Celery 投递失败,将等待恢复任务兜底"
|
||||
return out
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/segments/{segment_id}",
|
||||
response_model=ShotSegmentDeleteOut,
|
||||
|
||||
@@ -228,7 +228,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# 拆镜复刻配置。
|
||||
# 原始上传视频和拆镜片段都属于 uploads 素材域;只有 generate 生成结果走 token 验签。
|
||||
SHOT_ANALYSIS_TIMEOUT_SECONDS: int = 600
|
||||
SHOT_ANALYSIS_TIMEOUT_SECONDS: int = 3600
|
||||
SHOT_ANALYSIS_TEMPERATURE: float = 0.1
|
||||
SHOT_ANALYSIS_MAX_TOKENS: int = 5000
|
||||
SHOT_ANALYSIS_VIDEO_FPS: float = 1.0
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class AdminUploadSceneEnum(StrEnum):
|
||||
"""管理后台全局上传场景。"""
|
||||
|
||||
SYSTEM_LOGO = "system_logo"
|
||||
SYSTEM_PDF = "system_pdf"
|
||||
OPEN_TYPE_THUMB = "open_type_thumb"
|
||||
HOME_MATERIAL_REFERENCE = "home_material_reference"
|
||||
ADMIN_COMMON = "admin_common"
|
||||
|
||||
|
||||
class AdminUploadResourceTypeEnum(StrEnum):
|
||||
"""管理后台全局上传资源类型。"""
|
||||
|
||||
IMAGE = "image"
|
||||
VIDEO = "video"
|
||||
AUDIO = "audio"
|
||||
PDF = "pdf"
|
||||
FILE = "file"
|
||||
|
||||
|
||||
class AdminUploadLogEventEnum(StrEnum):
|
||||
"""管理后台上传步骤日志事件。"""
|
||||
|
||||
VALIDATE_STARTED = "ADMIN_UPLOAD_VALIDATE_STARTED"
|
||||
VALIDATE_SUCCESS = "ADMIN_UPLOAD_VALIDATE_SUCCESS"
|
||||
SAVE_STARTED = "ADMIN_UPLOAD_SAVE_STARTED"
|
||||
SAVE_SUCCESS = "ADMIN_UPLOAD_SAVE_SUCCESS"
|
||||
DB_RECORD_SUCCESS = "ADMIN_UPLOAD_DB_RECORD_SUCCESS"
|
||||
FAILED = "ADMIN_UPLOAD_FAILED"
|
||||
|
||||
|
||||
ADMIN_UPLOAD_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
|
||||
ADMIN_UPLOAD_VIDEO_EXTENSIONS = {".mp4", ".mov", ".m4v", ".webm"}
|
||||
ADMIN_UPLOAD_AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac"}
|
||||
ADMIN_UPLOAD_PDF_EXTENSIONS = {".pdf"}
|
||||
|
||||
ADMIN_UPLOAD_IMAGE_MAX_BYTES = 20 * 1024 * 1024
|
||||
ADMIN_UPLOAD_VIDEO_MAX_BYTES = 300 * 1024 * 1024
|
||||
ADMIN_UPLOAD_AUDIO_MAX_BYTES = 30 * 1024 * 1024
|
||||
ADMIN_UPLOAD_PDF_MAX_BYTES = 20 * 1024 * 1024
|
||||
ADMIN_UPLOAD_FILE_MAX_BYTES = 100 * 1024 * 1024
|
||||
ADMIN_UPLOAD_FILE_NAME_MAX_LEN = 255
|
||||
|
||||
ADMIN_UPLOAD_SCENE_LABELS: dict[str, str] = {
|
||||
AdminUploadSceneEnum.SYSTEM_LOGO.value: "系统Logo",
|
||||
AdminUploadSceneEnum.SYSTEM_PDF.value: "系统PDF",
|
||||
AdminUploadSceneEnum.OPEN_TYPE_THUMB.value: "开户方式缩略图",
|
||||
AdminUploadSceneEnum.HOME_MATERIAL_REFERENCE.value: "首页素材附件",
|
||||
AdminUploadSceneEnum.ADMIN_COMMON.value: "后台公共上传",
|
||||
}
|
||||
@@ -63,6 +63,17 @@ class HomeMaterialPublicResponseMode(StrEnum):
|
||||
FLAT = "flat"
|
||||
|
||||
|
||||
class HomeMaterialLogEventEnum(StrEnum):
|
||||
"""首页素材步骤日志事件。"""
|
||||
|
||||
GENERATION_CONFIG_VALIDATE_STARTED = "HOME_MATERIAL_GENERATION_CONFIG_VALIDATE_STARTED"
|
||||
GENERATION_CONFIG_VALIDATE_SUCCESS = "HOME_MATERIAL_GENERATION_CONFIG_VALIDATE_SUCCESS"
|
||||
GENERATION_CONFIG_UPDATE_STARTED = "HOME_MATERIAL_GENERATION_CONFIG_UPDATE_STARTED"
|
||||
GENERATION_CONFIG_UPDATE_SUCCESS = "HOME_MATERIAL_GENERATION_CONFIG_UPDATE_SUCCESS"
|
||||
GENERATION_CONFIG_UPDATE_FAILED = "HOME_MATERIAL_GENERATION_CONFIG_UPDATE_FAILED"
|
||||
MEDIA_REFERENCES_PARSE_FAILED = "HOME_MATERIAL_MEDIA_REFERENCES_PARSE_FAILED"
|
||||
|
||||
|
||||
class HomeMaterialOperationEnum(StrEnum):
|
||||
"""后台操作日志 action 枚举。"""
|
||||
|
||||
@@ -75,6 +86,7 @@ class HomeMaterialOperationEnum(StrEnum):
|
||||
WATERMARK_DELETE = "删除首页素材水印"
|
||||
ASSET_UPLOAD = "上传首页素材"
|
||||
ASSET_UPDATE = "修改首页素材"
|
||||
ASSET_GENERATION_CONFIG_UPDATE = "修改首页素材生成配置"
|
||||
ASSET_DELETE = "删除首页素材"
|
||||
ASSET_REGENERATE_WATERMARK = "重新生成首页素材水印"
|
||||
|
||||
@@ -83,6 +95,10 @@ HOME_MATERIAL_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
|
||||
HOME_MATERIAL_VIDEO_EXTENSIONS = {".mp4", ".mov", ".m4v", ".webm"}
|
||||
HOME_MATERIAL_WATERMARK_EXTENSIONS = {".png", ".webp", ".jpg", ".jpeg"}
|
||||
|
||||
HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN = 8000
|
||||
HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT = 20
|
||||
HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN = 20000
|
||||
|
||||
HOME_MATERIAL_DEFAULT_CONFIG = {
|
||||
"enabled": False,
|
||||
"title": "行业素材案例",
|
||||
|
||||
@@ -178,6 +178,15 @@ class PrivatePortraitEventType(str, Enum):
|
||||
ASSET_CREATE_START = "ASSET_CREATE_START"
|
||||
ASSET_CREATE_SUCCESS = "ASSET_CREATE_SUCCESS"
|
||||
ASSET_CREATE_FAILED = "ASSET_CREATE_FAILED"
|
||||
ASSET_UPLOAD_START = "ASSET_UPLOAD_START"
|
||||
ASSET_UPLOAD_SUCCESS = "ASSET_UPLOAD_SUCCESS"
|
||||
ASSET_UPLOAD_FAILED = "ASSET_UPLOAD_FAILED"
|
||||
ASSET_UPLOAD_BIND_START = "ASSET_UPLOAD_BIND_START"
|
||||
ASSET_UPLOAD_BIND_SUCCESS = "ASSET_UPLOAD_BIND_SUCCESS"
|
||||
ASSET_UPLOAD_BIND_FAILED = "ASSET_UPLOAD_BIND_FAILED"
|
||||
ASSET_UPLOAD_RELEASE_START = "ASSET_UPLOAD_RELEASE_START"
|
||||
ASSET_UPLOAD_RELEASE_SUCCESS = "ASSET_UPLOAD_RELEASE_SUCCESS"
|
||||
ASSET_UPLOAD_RELEASE_FAILED = "ASSET_UPLOAD_RELEASE_FAILED"
|
||||
|
||||
ASSET_SYNC_START = "ASSET_SYNC_START"
|
||||
ASSET_SYNC_SUCCESS = "ASSET_SYNC_SUCCESS"
|
||||
|
||||
@@ -133,6 +133,10 @@ class ShotReplicateLogEventEnum(StrEnum):
|
||||
SPLIT_BY_AI_SUBMITTED = "SHOT_SPLIT_BY_AI_SUBMITTED"
|
||||
SPLIT_CUSTOM_SUBMITTED = "SHOT_SPLIT_CUSTOM_SUBMITTED"
|
||||
SEGMENT_DELETED = "SHOT_SEGMENT_DELETED"
|
||||
SEGMENT_SPLIT_RETRY_RECEIVED = "SHOT_SEGMENT_SPLIT_RETRY_RECEIVED"
|
||||
SEGMENT_SPLIT_RETRY_SUBMITTED = "SHOT_SEGMENT_SPLIT_RETRY_SUBMITTED"
|
||||
SEGMENT_SPLIT_RETRY_REJECTED = "SHOT_SEGMENT_SPLIT_RETRY_REJECTED"
|
||||
SEGMENT_SPLIT_RETRY_DISPATCH_FAILED = "SHOT_SEGMENT_SPLIT_RETRY_DISPATCH_FAILED"
|
||||
|
||||
|
||||
class ShotReplicateRemoteActionEnum(StrEnum):
|
||||
|
||||
@@ -7,8 +7,12 @@ class UploadResourceModuleEnum(StrEnum):
|
||||
"""上传资源所属业务模块。"""
|
||||
|
||||
COMMON = "common"
|
||||
ADMIN_UPLOAD = "admin_upload"
|
||||
HOME_MATERIAL = "home_material"
|
||||
HOT_OPENING_REPLICATE = "hot_opening_replicate"
|
||||
SHOT_REPLICATE = "shot_replicate"
|
||||
PRIVATE_PORTRAIT_REAL = "private_portrait_real"
|
||||
PRIVATE_PORTRAIT_VIRTUAL = "private_portrait_virtual"
|
||||
|
||||
|
||||
class UploadResourceTypeEnum(StrEnum):
|
||||
@@ -18,6 +22,8 @@ class UploadResourceTypeEnum(StrEnum):
|
||||
VIDEO = "video"
|
||||
AUDIO = "audio"
|
||||
SHOT_SEGMENT = "shot_segment"
|
||||
PDF = "pdf"
|
||||
FILE = "file"
|
||||
|
||||
|
||||
class UploadResourceBindStatusEnum(StrEnum):
|
||||
@@ -76,6 +82,10 @@ class UploadResourceSourceModelEnum(StrEnum):
|
||||
MODULE_GENERATION_PROJECT = "ModuleGenerationProject"
|
||||
SHOT_REPLICATE_TASK_SET = "ShotReplicateTaskSet"
|
||||
SHOT_REPLICATE_SEGMENT = "ShotReplicateSegment"
|
||||
HOME_MATERIAL_ASSET = "HomeMaterialAsset"
|
||||
SYSTEM_CONFIG = "SystemConfig"
|
||||
OPEN_TYPE = "OpenType"
|
||||
PRIVATE_PORTRAIT_ASSET = "PrivatePortraitAsset"
|
||||
|
||||
|
||||
class UploadResourceEventEnum(StrEnum):
|
||||
@@ -105,6 +115,7 @@ class UploadResourceEventEnum(StrEnum):
|
||||
BIND_SUCCESS = "bind_success"
|
||||
BIND_CONFLICT = "bind_conflict"
|
||||
BIND_SKIPPED = "bind_skipped"
|
||||
BIND_FAILED = "bind_failed"
|
||||
|
||||
BACKFILL_START = "backfill_start"
|
||||
BACKFILL_FILE_MATCHED = "backfill_file_matched"
|
||||
@@ -155,8 +166,12 @@ class UploadResourceEventEnum(StrEnum):
|
||||
|
||||
UPLOAD_RESOURCE_MODULE_LABELS: dict[str, str] = {
|
||||
UploadResourceModuleEnum.COMMON.value: "普通上传",
|
||||
UploadResourceModuleEnum.ADMIN_UPLOAD.value: "管理后台上传",
|
||||
UploadResourceModuleEnum.HOME_MATERIAL.value: "首页素材",
|
||||
UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value: "爆款开头复刻",
|
||||
UploadResourceModuleEnum.SHOT_REPLICATE.value: "拆镜复刻",
|
||||
UploadResourceModuleEnum.PRIVATE_PORTRAIT_REAL.value: "私域真人素材",
|
||||
UploadResourceModuleEnum.PRIVATE_PORTRAIT_VIRTUAL.value: "私域虚拟素材",
|
||||
}
|
||||
|
||||
UPLOAD_RESOURCE_TYPE_LABELS: dict[str, str] = {
|
||||
@@ -164,4 +179,6 @@ UPLOAD_RESOURCE_TYPE_LABELS: dict[str, str] = {
|
||||
UploadResourceTypeEnum.VIDEO.value: "视频",
|
||||
UploadResourceTypeEnum.AUDIO.value: "音频",
|
||||
UploadResourceTypeEnum.SHOT_SEGMENT.value: "拆镜切片",
|
||||
UploadResourceTypeEnum.PDF.value: "PDF",
|
||||
UploadResourceTypeEnum.FILE.value: "文件",
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ async def lifespan(app: FastAPI):
|
||||
os.makedirs(settings.UPLOAD_LOCAL_PATH, exist_ok=True)
|
||||
await init_database()
|
||||
await init_redis()
|
||||
await _seed_data()
|
||||
# await _seed_data()
|
||||
|
||||
# Start task queue (handles both video and image generation)
|
||||
from app.services.video_queue import task_queue
|
||||
|
||||
@@ -25,3 +25,6 @@ class CreditRatio(Base, TimestampMixin):
|
||||
input_video_ratio: Mapped[float] = mapped_column(Float, default=1.0)
|
||||
input_video_base_credits: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
input_video_per_second_credits: Mapped[float] = mapped_column(Float, default=0.5)
|
||||
input_image_ratio: Mapped[float] = mapped_column(Float, default=1.0)
|
||||
input_image_base_credits: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
input_image_per_image_credits: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
||||
@@ -40,6 +40,8 @@ class HomeMaterialAsset(Base, TimestampMixin, SoftDeleteMixin):
|
||||
cover_storage_path: Mapped[str | None] = mapped_column(Text, nullable=True, comment="视频封面本地路径")
|
||||
watermark_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="水印图片ID")
|
||||
watermark_config_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="水印配置快照JSON")
|
||||
generation_prompt: Mapped[str | None] = mapped_column(Text, nullable=True, comment="生成提词")
|
||||
media_references_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="附件/参考素材JSON字符串")
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
default=HomeMaterialAssetStatus.DRAFT.value,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.enums.admin_upload import AdminUploadResourceTypeEnum, AdminUploadSceneEnum
|
||||
|
||||
|
||||
class AdminUploadMediaReferenceOut(BaseModel):
|
||||
url: str = Field(..., description="附件URL。")
|
||||
type: str = Field(..., description="附件类型:image/video/audio/pdf/file。")
|
||||
name: str | None = Field(None, description="附件名称。")
|
||||
duration: float | None = Field(None, description="音视频时长,单位秒。")
|
||||
source: str = Field("admin_upload", description="来源标识。")
|
||||
upload_resource_id: str | None = Field(None, description="UploadResource资源ID。")
|
||||
display_url: str | None = Field(None, description="展示URL。")
|
||||
preview_url: str | None = Field(None, description="预览URL。")
|
||||
role: str | None = Field(None, description="参考素材角色。")
|
||||
|
||||
|
||||
class AdminUploadFileOut(BaseModel):
|
||||
resource_id: str = Field(..., description="UploadResource资源ID。")
|
||||
scene: AdminUploadSceneEnum = Field(..., description="上传场景。")
|
||||
module: str = Field(..., description="上传资源模块。")
|
||||
resource_type: AdminUploadResourceTypeEnum = Field(..., description="资源类型。")
|
||||
url: str = Field(..., description="文件URL。")
|
||||
file_name: str = Field(..., description="保存后的文件名。")
|
||||
original_file_name: str | None = Field(None, description="原始文件名。")
|
||||
file_size_bytes: int = Field(0, description="文件大小,单位字节。")
|
||||
duration_seconds: float | None = Field(None, description="音视频时长,单位秒。")
|
||||
media_reference: AdminUploadMediaReferenceOut | None = Field(None, description="兼容生成任务 media_references 的附件引用对象。")
|
||||
@@ -61,6 +61,24 @@ class CreditRatioCreate(BaseModel):
|
||||
description="传入视频每秒积分。视频生成时,用户上传参考视频每秒消耗的积分",
|
||||
examples=[0.5],
|
||||
)
|
||||
input_image_ratio: float = Field(
|
||||
default=1.0,
|
||||
ge=0,
|
||||
description="传入图片积分倍率。图片生成时,用户上传参考图片的额外积分倍率",
|
||||
examples=[1.0],
|
||||
)
|
||||
input_image_base_credits: float = Field(
|
||||
default=0.0,
|
||||
ge=0,
|
||||
description="传入图片基础积分。图片生成时,用户上传参考图片的基础积分",
|
||||
examples=[0.0],
|
||||
)
|
||||
input_image_per_image_credits: float = Field(
|
||||
default=0.0,
|
||||
ge=0,
|
||||
description="传入图片每张积分。图片生成时,用户上传参考图片每张消耗的积分",
|
||||
examples=[0.0],
|
||||
)
|
||||
|
||||
|
||||
class CreditRatioOut(CreditRatioCreate):
|
||||
|
||||
@@ -3,9 +3,11 @@ from __future__ import annotations
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.enums.home_material import (
|
||||
HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN,
|
||||
HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT,
|
||||
HomeMaterialAssetStatus,
|
||||
HomeMaterialMediaType,
|
||||
HomeMaterialPublicResponseMode,
|
||||
@@ -92,6 +94,40 @@ class HomeMaterialWatermarkListOut(BaseModel):
|
||||
total: int = Field(0, description="符合条件的水印总数。")
|
||||
|
||||
|
||||
|
||||
|
||||
class HomeMaterialMediaReference(BaseModel):
|
||||
url: str = Field(..., min_length=1, description="附件URL。")
|
||||
type: str = Field(..., pattern=r"^(image|video|audio)$", description="附件类型:image/video/audio。")
|
||||
name: str | None = Field(None, max_length=255, description="附件名称。")
|
||||
duration: float | None = Field(None, ge=0, description="视频/音频时长,单位秒。")
|
||||
source: str | None = Field("admin_upload", max_length=64, description="来源标识。")
|
||||
upload_resource_id: str | None = Field(None, max_length=32, validation_alias=AliasChoices("upload_resource_id", "uploadResourceId"), description="UploadResource资源ID。")
|
||||
display_url: str | None = Field(None, validation_alias=AliasChoices("display_url", "displayUrl"), description="展示URL。")
|
||||
preview_url: str | None = Field(None, validation_alias=AliasChoices("preview_url", "previewUrl"), description="预览URL。")
|
||||
role: str | None = Field(None, max_length=64, description="参考素材角色。")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
@field_validator("url", "display_url", "preview_url", mode="before")
|
||||
@classmethod
|
||||
def clean_url(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def fill_urls_and_role(self) -> "HomeMaterialMediaReference":
|
||||
if not self.display_url:
|
||||
self.display_url = self.url
|
||||
if not self.preview_url:
|
||||
self.preview_url = self.display_url or self.url
|
||||
if not self.role:
|
||||
self.role = f"reference_{self.type}"
|
||||
return self
|
||||
|
||||
|
||||
class HomeMaterialTextWatermarkConfig(BaseModel):
|
||||
"""重复文字水印配置。字体固定由后端 HOME_MATERIAL_TEXT_WATERMARK_FONT 指定,不允许前端传字体,避免版权和一致性问题。"""
|
||||
|
||||
@@ -180,6 +216,8 @@ class HomeMaterialAssetOut(BaseModel):
|
||||
watermark_id: str | None = Field(None, description="图片水印ID。重复文字水印素材为空。")
|
||||
watermark_name: str | None = Field(None, description="图片水印名称,列表接口批量查询回填。重复文字水印显示为空。")
|
||||
watermark_config: HomeMaterialWatermarkConfig | dict[str, Any] | None = Field(None, description="水印配置快照。")
|
||||
generation_prompt: str | None = Field(None, description="生成提词。")
|
||||
media_references: list[HomeMaterialMediaReference] = Field(default_factory=list, description="附件/参考素材列表。")
|
||||
width: int | None = Field(None, description="素材宽度。")
|
||||
height: int | None = Field(None, description="素材高度。")
|
||||
duration_seconds: Decimal | float | None = Field(None, description="视频时长,图片为空。")
|
||||
@@ -205,10 +243,12 @@ class HomeMaterialAssetUpdate(BaseModel):
|
||||
title: str | None = Field(None, max_length=128, description="素材标题。")
|
||||
is_active: bool = Field(True, description="是否前台展示。")
|
||||
sort_order: int = Field(0, ge=0, le=999999, description="排序值。")
|
||||
generation_prompt: str | None = Field(None, max_length=HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN, description="生成提词。")
|
||||
media_references: list[HomeMaterialMediaReference] | None = Field(None, max_length=HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT, description="附件/参考素材列表。")
|
||||
|
||||
@field_validator("title")
|
||||
@field_validator("title", "generation_prompt")
|
||||
@classmethod
|
||||
def clean_title(cls, value: str | None) -> str | None:
|
||||
def clean_text(cls, value: str | None) -> str | None:
|
||||
value = (value or "").strip()
|
||||
return value or None
|
||||
|
||||
@@ -256,6 +296,8 @@ class HomeMaterialPublicAssetOut(BaseModel):
|
||||
width: int | None = Field(None, description="宽度。")
|
||||
height: int | None = Field(None, description="高度。")
|
||||
duration_seconds: Decimal | float | None = Field(None, description="视频时长。")
|
||||
generation_prompt: str | None = Field(None, description="生成提词。")
|
||||
media_references: list[HomeMaterialMediaReference] = Field(default_factory=list, description="附件/参考素材列表。")
|
||||
sort_order: int = Field(0, description="排序。")
|
||||
|
||||
|
||||
@@ -303,4 +345,6 @@ class HomeMaterialUploadResultOut(BaseModel):
|
||||
watermarked_url: str | None = Field(None, description="水印素材URL。")
|
||||
cover_url: str | None = Field(None, description="视频封面URL。")
|
||||
watermark_config: HomeMaterialWatermarkConfig | dict[str, Any] | None = Field(None, description="水印配置快照。")
|
||||
generation_prompt: str | None = Field(None, description="生成提词。")
|
||||
media_references: list[HomeMaterialMediaReference] = Field(default_factory=list, description="附件/参考素材列表。")
|
||||
message: str = Field("", description="提示消息。")
|
||||
|
||||
@@ -241,12 +241,27 @@ class PrivatePortraitAssetGroupOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PrivatePortraitUploadOut(BaseModel):
|
||||
url: str = Field(..., description="上传后的本地资源 URL,可直接用于创建私域素材")
|
||||
filename: str = Field(..., description="原始文件名或安全文件名")
|
||||
type: str = Field(..., description="资源类型:image/video")
|
||||
module: str = Field(..., description="上传模块:private_portrait_real/private_portrait_virtual")
|
||||
resource_id: str = Field(..., description="UploadResource.id。创建素材时必须作为 upload_resource_id 传回后端绑定素材")
|
||||
file_size_bytes: int = Field(0, description="文件大小,单位字节")
|
||||
duration_seconds: float | None = Field(None, description="视频素材秒数,图片为空")
|
||||
|
||||
|
||||
class PrivatePortraitAssetCreate(BaseModel):
|
||||
url: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="已上传到本系统且可公网访问的素材 URL。支持图片/视频,后端会转换为公网地址后调用火山 CreateAsset。",
|
||||
)
|
||||
upload_resource_id: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="可选但新客户端必须传:真人/虚拟专用上传接口返回的 UploadResource.id。后端 CreateAsset 成功后会绑定到 PrivatePortraitAsset。",
|
||||
)
|
||||
asset_type: str = Field(
|
||||
default=PrivatePortraitAssetType.IMAGE.value,
|
||||
description="素材类型枚举:Image=图片,Video=视频,Audio=音频。当前业务仅开放 Image / Video,Audio 会被拒绝。",
|
||||
@@ -273,10 +288,10 @@ class PrivatePortraitAssetCreate(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_video_duration(self) -> "PrivatePortraitAssetCreate":
|
||||
if self.asset_type == PrivatePortraitAssetType.VIDEO.value:
|
||||
# 允许新客户端只传 upload_resource_id,服务层会优先从 UploadResource.duration_seconds 回填秒数。
|
||||
# 如果前端已传 video_duration,则这里先做范围校验,避免无效视频进入远端入库。
|
||||
if self.asset_type == PrivatePortraitAssetType.VIDEO.value and self.video_duration is not None:
|
||||
duration = self.video_duration
|
||||
if duration is None:
|
||||
raise ValueError("Video 素材必须提供 video_duration")
|
||||
if duration < PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS:
|
||||
raise ValueError(f"视频素材最短不能少于 {PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS} 秒")
|
||||
if duration > PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS:
|
||||
|
||||
@@ -740,6 +740,27 @@ class ShotReanalyzeOut(BaseModel):
|
||||
analysis_status: str = Field(..., description="重置后的分析状态")
|
||||
celery_task_name: str = Field(..., description="已投递或待投递的 Celery 任务名")
|
||||
|
||||
class ShotSplitRetryRequest(BaseModel):
|
||||
force: bool = Field(False, description="是否强制重置;当前只用于兼容入参,已完成切片仍会拒绝重切,避免旧文件覆盖和资源释放复杂化")
|
||||
reason: str | None = Field(None, max_length=200, description="切片失败重试原因,会写入模块日志")
|
||||
|
||||
@field_validator("reason", mode="before")
|
||||
@classmethod
|
||||
def _strip_reason(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = str(value).strip()
|
||||
return value or None
|
||||
|
||||
|
||||
class ShotSegmentSplitRetryOut(BaseModel):
|
||||
message: str = Field(..., description="操作结果提示")
|
||||
task_set_id: str = Field(..., description="拆镜总任务集ID")
|
||||
segment_id: str = Field(..., description="拆镜片段ID")
|
||||
split_status: str = Field(..., description="重置后的切片状态")
|
||||
celery_task_name: str = Field(..., description="已投递或待投递的 Celery 任务名")
|
||||
|
||||
|
||||
class ShotSplitByAIOut(BaseModel):
|
||||
task_set_id: str = Field(..., description="拆镜总任务集ID")
|
||||
status: str = Field(..., description="总任务状态:pending_analysis/analyzing/analysis_completed/analysis_failed/splitting/split_completed/partial_failed/failed/deleted")
|
||||
|
||||
@@ -65,7 +65,7 @@ class UploadResourceHistoryItemOut(BaseModel):
|
||||
history_source: Literal["upload_resource"] = Field("upload_resource", description="素材云历史来源固定为 upload_resource")
|
||||
history_source_label: str = Field("历史上传素材", description="素材云历史来源中文名称")
|
||||
|
||||
module: str = Field(..., description="上传资源所属模块:common=普通上传,hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻")
|
||||
module: str = Field(..., description="上传资源所属模块:common=普通上传,hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻,private_portrait_real=私域真人素材,private_portrait_virtual=私域虚拟素材")
|
||||
module_label: str = Field(..., description="上传资源所属模块中文名称")
|
||||
resource_type: Literal["image", "video", "audio"] = Field(..., description="资源类型:image=图片,video=视频,audio=音频")
|
||||
resource_type_label: str = Field(..., description="资源类型中文名称")
|
||||
@@ -182,4 +182,6 @@ UPLOAD_RESOURCE_HISTORY_ALLOWED_MODULES = {
|
||||
UploadResourceModuleEnum.COMMON.value,
|
||||
UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
|
||||
UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
UploadResourceModuleEnum.PRIVATE_PORTRAIT_REAL.value,
|
||||
UploadResourceModuleEnum.PRIVATE_PORTRAIT_VIRTUAL.value,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.services.admin_upload.service import admin_upload_service
|
||||
|
||||
__all__ = ["admin_upload_service"]
|
||||
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.admin_upload import AdminUploadLogEventEnum, AdminUploadResourceTypeEnum, AdminUploadSceneEnum
|
||||
from app.enums.common import LogEventStatusEnum, LogSourceEnum
|
||||
from app.enums.upload_resource import (
|
||||
UploadResourceBindStatusEnum,
|
||||
UploadResourceCreatedByEnum,
|
||||
UploadResourceDeletePolicyEnum,
|
||||
UploadResourceDurationSourceEnum,
|
||||
UploadResourceModuleEnum,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.schemas.admin_upload import AdminUploadFileOut, AdminUploadMediaReferenceOut
|
||||
from app.services.admin_upload.storage import save_upload_file
|
||||
from app.services.operation_log_service import build_exception_detail, log_operation_event
|
||||
from app.services.upload_resource.core_service import record_external_upload_resource
|
||||
|
||||
|
||||
def _duration(value: float | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
v = float(value)
|
||||
return round(v, 3) if v >= 0 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _module_for_scene(scene: AdminUploadSceneEnum) -> str:
|
||||
if scene == AdminUploadSceneEnum.HOME_MATERIAL_REFERENCE:
|
||||
return UploadResourceModuleEnum.HOME_MATERIAL.value
|
||||
return UploadResourceModuleEnum.ADMIN_UPLOAD.value
|
||||
|
||||
|
||||
def _role_for_type(resource_type: AdminUploadResourceTypeEnum) -> str | None:
|
||||
if resource_type == AdminUploadResourceTypeEnum.IMAGE:
|
||||
return "reference_image"
|
||||
if resource_type == AdminUploadResourceTypeEnum.VIDEO:
|
||||
return "reference_video"
|
||||
if resource_type == AdminUploadResourceTypeEnum.AUDIO:
|
||||
return "reference_audio"
|
||||
return None
|
||||
|
||||
|
||||
def _media_reference(*, url: str, resource_type: AdminUploadResourceTypeEnum, name: str | None, duration_seconds: float | None, resource_id: str) -> AdminUploadMediaReferenceOut:
|
||||
return AdminUploadMediaReferenceOut(
|
||||
url=url,
|
||||
type=resource_type.value,
|
||||
name=name,
|
||||
duration=duration_seconds,
|
||||
source="admin_upload",
|
||||
upload_resource_id=resource_id,
|
||||
display_url=url,
|
||||
preview_url=url,
|
||||
role=_role_for_type(resource_type),
|
||||
)
|
||||
|
||||
|
||||
def _log(event_type: str, *, admin: User, scene: str, resource_type: str, status: str = LogEventStatusEnum.SUCCESS.value, message: str | None = None, detail: dict[str, Any] | None = None, error: str | None = None) -> None:
|
||||
log_operation_event(
|
||||
domain="admin_upload",
|
||||
module="admin_upload",
|
||||
event_type=event_type,
|
||||
event_status=status,
|
||||
source=LogSourceEnum.API.value,
|
||||
user_id=admin.id,
|
||||
message=message,
|
||||
detail={"scene": scene, "resource_type": resource_type, **(detail or {})},
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
class AdminUploadService:
|
||||
async def upload_file(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
file: UploadFile,
|
||||
scene: AdminUploadSceneEnum,
|
||||
resource_type: AdminUploadResourceTypeEnum,
|
||||
admin: User,
|
||||
duration_seconds: float | None = None,
|
||||
) -> AdminUploadFileOut:
|
||||
duration = _duration(duration_seconds)
|
||||
_log(
|
||||
AdminUploadLogEventEnum.VALIDATE_STARTED.value,
|
||||
admin=admin,
|
||||
scene=scene.value,
|
||||
resource_type=resource_type.value,
|
||||
status=LogEventStatusEnum.STARTED.value,
|
||||
detail={"filename": file.filename, "content_type": file.content_type},
|
||||
)
|
||||
stored = None
|
||||
try:
|
||||
_log(
|
||||
AdminUploadLogEventEnum.VALIDATE_SUCCESS.value,
|
||||
admin=admin,
|
||||
scene=scene.value,
|
||||
resource_type=resource_type.value,
|
||||
detail={"filename": file.filename, "duration_seconds": duration},
|
||||
)
|
||||
_log(
|
||||
AdminUploadLogEventEnum.SAVE_STARTED.value,
|
||||
admin=admin,
|
||||
scene=scene.value,
|
||||
resource_type=resource_type.value,
|
||||
status=LogEventStatusEnum.STARTED.value,
|
||||
detail={"filename": file.filename},
|
||||
)
|
||||
stored = await save_upload_file(file, scene=scene, resource_type=resource_type, admin_id=admin.id)
|
||||
_log(
|
||||
AdminUploadLogEventEnum.SAVE_SUCCESS.value,
|
||||
admin=admin,
|
||||
scene=scene.value,
|
||||
resource_type=resource_type.value,
|
||||
detail={"url": stored.file_url, "storage_path": stored.storage_path, "file_size_bytes": stored.file_size_bytes},
|
||||
)
|
||||
module = _module_for_scene(scene)
|
||||
resource = await record_external_upload_resource(
|
||||
db,
|
||||
user_id=admin.id,
|
||||
module=module,
|
||||
resource_type=resource_type.value,
|
||||
resource_url=stored.file_url,
|
||||
storage_path=stored.storage_path,
|
||||
file_size_bytes=stored.file_size_bytes,
|
||||
file_name=stored.file_name,
|
||||
mime_type=file.content_type,
|
||||
duration_seconds=duration,
|
||||
duration_source=UploadResourceDurationSourceEnum.CLIENT.value if duration is not None else None,
|
||||
bind_status=UploadResourceBindStatusEnum.PENDING.value,
|
||||
delete_policy=UploadResourceDeletePolicyEnum.USER_DELETABLE.value,
|
||||
created_by=UploadResourceCreatedByEnum.API.value,
|
||||
metadata={
|
||||
"scene": scene.value,
|
||||
"original_filename": stored.original_file_name,
|
||||
"client_duration_seconds": duration_seconds,
|
||||
"source": "admin_upload",
|
||||
},
|
||||
)
|
||||
await db.refresh(resource)
|
||||
_log(
|
||||
AdminUploadLogEventEnum.DB_RECORD_SUCCESS.value,
|
||||
admin=admin,
|
||||
scene=scene.value,
|
||||
resource_type=resource_type.value,
|
||||
detail={"resource_id": resource.id, "url": stored.file_url, "module": module},
|
||||
)
|
||||
media_reference = _media_reference(
|
||||
url=stored.file_url,
|
||||
resource_type=resource_type,
|
||||
name=stored.original_file_name or stored.file_name,
|
||||
duration_seconds=resource.duration_seconds,
|
||||
resource_id=resource.id,
|
||||
)
|
||||
return AdminUploadFileOut(
|
||||
resource_id=resource.id,
|
||||
scene=scene,
|
||||
module=module,
|
||||
resource_type=resource_type,
|
||||
url=stored.file_url,
|
||||
file_name=stored.file_name,
|
||||
original_file_name=stored.original_file_name,
|
||||
file_size_bytes=stored.file_size_bytes,
|
||||
duration_seconds=resource.duration_seconds,
|
||||
media_reference=media_reference,
|
||||
)
|
||||
except Exception as exc:
|
||||
_log(
|
||||
AdminUploadLogEventEnum.FAILED.value,
|
||||
admin=admin,
|
||||
scene=scene.value,
|
||||
resource_type=resource_type.value,
|
||||
status=LogEventStatusEnum.FAILED.value,
|
||||
detail=build_exception_detail(exc, {"filename": file.filename}),
|
||||
error=str(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
admin_upload_service = AdminUploadService()
|
||||
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.admin_upload import (
|
||||
ADMIN_UPLOAD_AUDIO_EXTENSIONS,
|
||||
ADMIN_UPLOAD_AUDIO_MAX_BYTES,
|
||||
ADMIN_UPLOAD_FILE_MAX_BYTES,
|
||||
ADMIN_UPLOAD_FILE_NAME_MAX_LEN,
|
||||
ADMIN_UPLOAD_IMAGE_EXTENSIONS,
|
||||
ADMIN_UPLOAD_IMAGE_MAX_BYTES,
|
||||
ADMIN_UPLOAD_PDF_EXTENSIONS,
|
||||
ADMIN_UPLOAD_PDF_MAX_BYTES,
|
||||
ADMIN_UPLOAD_VIDEO_EXTENSIONS,
|
||||
ADMIN_UPLOAD_VIDEO_MAX_BYTES,
|
||||
AdminUploadResourceTypeEnum,
|
||||
AdminUploadSceneEnum,
|
||||
)
|
||||
|
||||
CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StoredAdminUploadFile:
|
||||
storage_path: str
|
||||
file_url: str
|
||||
file_name: str
|
||||
original_file_name: str | None
|
||||
file_size_bytes: int
|
||||
|
||||
|
||||
def _upload_root() -> Path:
|
||||
return Path(settings.UPLOAD_LOCAL_PATH).resolve()
|
||||
|
||||
|
||||
def _safe_original_name(filename: str | None) -> str | None:
|
||||
if not filename:
|
||||
return None
|
||||
name = Path(filename).name.strip()
|
||||
name = re.sub(r"[\\/\r\n\t]+", "_", name)
|
||||
return name[:ADMIN_UPLOAD_FILE_NAME_MAX_LEN] or None
|
||||
|
||||
|
||||
def _ext(filename: str | None) -> str:
|
||||
return Path(filename or "").suffix.lower()
|
||||
|
||||
|
||||
def _allowed_extensions(resource_type: AdminUploadResourceTypeEnum) -> set[str] | None:
|
||||
if resource_type == AdminUploadResourceTypeEnum.IMAGE:
|
||||
return ADMIN_UPLOAD_IMAGE_EXTENSIONS
|
||||
if resource_type == AdminUploadResourceTypeEnum.VIDEO:
|
||||
return ADMIN_UPLOAD_VIDEO_EXTENSIONS
|
||||
if resource_type == AdminUploadResourceTypeEnum.AUDIO:
|
||||
return ADMIN_UPLOAD_AUDIO_EXTENSIONS
|
||||
if resource_type == AdminUploadResourceTypeEnum.PDF:
|
||||
return ADMIN_UPLOAD_PDF_EXTENSIONS
|
||||
return None
|
||||
|
||||
|
||||
def _max_bytes(resource_type: AdminUploadResourceTypeEnum) -> int:
|
||||
if resource_type == AdminUploadResourceTypeEnum.IMAGE:
|
||||
return ADMIN_UPLOAD_IMAGE_MAX_BYTES
|
||||
if resource_type == AdminUploadResourceTypeEnum.VIDEO:
|
||||
return ADMIN_UPLOAD_VIDEO_MAX_BYTES
|
||||
if resource_type == AdminUploadResourceTypeEnum.AUDIO:
|
||||
return ADMIN_UPLOAD_AUDIO_MAX_BYTES
|
||||
if resource_type == AdminUploadResourceTypeEnum.PDF:
|
||||
return ADMIN_UPLOAD_PDF_MAX_BYTES
|
||||
return ADMIN_UPLOAD_FILE_MAX_BYTES
|
||||
|
||||
|
||||
def validate_file_name(filename: str | None, resource_type: AdminUploadResourceTypeEnum) -> str:
|
||||
safe_name = _safe_original_name(filename)
|
||||
if not safe_name:
|
||||
raise HTTPException(status_code=400, detail="请选择文件")
|
||||
allowed = _allowed_extensions(resource_type)
|
||||
ext = _ext(safe_name)
|
||||
if allowed is not None and ext not in allowed:
|
||||
if resource_type == AdminUploadResourceTypeEnum.IMAGE:
|
||||
raise HTTPException(status_code=400, detail="仅支持 jpg/jpeg/png/webp/gif 图片")
|
||||
if resource_type == AdminUploadResourceTypeEnum.VIDEO:
|
||||
raise HTTPException(status_code=400, detail="仅支持 mp4/mov/m4v/webm 视频")
|
||||
if resource_type == AdminUploadResourceTypeEnum.AUDIO:
|
||||
raise HTTPException(status_code=400, detail="仅支持 mp3/wav/m4a/aac 音频")
|
||||
if resource_type == AdminUploadResourceTypeEnum.PDF:
|
||||
raise HTTPException(status_code=400, detail="仅支持 PDF 文件")
|
||||
return safe_name
|
||||
|
||||
|
||||
def _kind_dir(resource_type: AdminUploadResourceTypeEnum) -> str:
|
||||
if resource_type == AdminUploadResourceTypeEnum.IMAGE:
|
||||
return "images"
|
||||
if resource_type == AdminUploadResourceTypeEnum.VIDEO:
|
||||
return "videos"
|
||||
if resource_type == AdminUploadResourceTypeEnum.AUDIO:
|
||||
return "audios"
|
||||
return "files"
|
||||
|
||||
|
||||
def _scene_base_dir(scene: AdminUploadSceneEnum, resource_type: AdminUploadResourceTypeEnum) -> Path:
|
||||
if scene == AdminUploadSceneEnum.HOME_MATERIAL_REFERENCE:
|
||||
if resource_type not in {AdminUploadResourceTypeEnum.IMAGE, AdminUploadResourceTypeEnum.VIDEO, AdminUploadResourceTypeEnum.AUDIO}:
|
||||
raise HTTPException(status_code=400, detail="首页素材附件仅支持图片、视频、音频")
|
||||
return Path("home_materials") / "references" / _kind_dir(resource_type)
|
||||
if scene == AdminUploadSceneEnum.SYSTEM_LOGO:
|
||||
if resource_type != AdminUploadResourceTypeEnum.IMAGE:
|
||||
raise HTTPException(status_code=400, detail="系统Logo仅支持图片")
|
||||
return Path("admin_uploads") / "system_logo" / "images"
|
||||
if scene == AdminUploadSceneEnum.SYSTEM_PDF:
|
||||
if resource_type != AdminUploadResourceTypeEnum.PDF:
|
||||
raise HTTPException(status_code=400, detail="系统PDF仅支持PDF文件")
|
||||
return Path("admin_uploads") / "system_pdf" / "files"
|
||||
if scene == AdminUploadSceneEnum.OPEN_TYPE_THUMB:
|
||||
if resource_type != AdminUploadResourceTypeEnum.IMAGE:
|
||||
raise HTTPException(status_code=400, detail="开户方式缩略图仅支持图片")
|
||||
return Path("admin_uploads") / "open_type_thumb" / "images"
|
||||
return Path("admin_uploads") / "common" / _kind_dir(resource_type)
|
||||
|
||||
|
||||
def build_target(scene: AdminUploadSceneEnum, resource_type: AdminUploadResourceTypeEnum, admin_id: str, original_filename: str | None) -> tuple[Path, str, str]:
|
||||
safe_original = validate_file_name(original_filename, resource_type)
|
||||
now = datetime.now()
|
||||
date_dir = now.strftime("%Y/%m/%d")
|
||||
timestamp = now.strftime("%Y%m%d_%H%M%S")
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
ext = _ext(safe_original)
|
||||
prefix = {
|
||||
AdminUploadResourceTypeEnum.IMAGE: "admin_img",
|
||||
AdminUploadResourceTypeEnum.VIDEO: "admin_video",
|
||||
AdminUploadResourceTypeEnum.AUDIO: "admin_audio",
|
||||
AdminUploadResourceTypeEnum.PDF: "admin_pdf",
|
||||
}.get(resource_type, "admin_file")
|
||||
file_name = f"{prefix}_{admin_id}_{timestamp}_{suffix}{ext}"
|
||||
rel_dir = _scene_base_dir(scene, resource_type) / date_dir
|
||||
storage_path = _upload_root() / rel_dir / file_name
|
||||
file_url = f"/uploads/{(rel_dir / file_name).as_posix()}"
|
||||
return storage_path, file_url, file_name
|
||||
|
||||
|
||||
async def save_upload_file(file: UploadFile, *, scene: AdminUploadSceneEnum, resource_type: AdminUploadResourceTypeEnum, admin_id: str) -> StoredAdminUploadFile:
|
||||
original_file_name = validate_file_name(file.filename, resource_type)
|
||||
max_bytes = _max_bytes(resource_type)
|
||||
fd, temp_path = tempfile.mkstemp(prefix="admin_upload_", suffix=".tmp")
|
||||
total = 0
|
||||
final_path: Path | None = None
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as out:
|
||||
while True:
|
||||
chunk = await file.read(CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise HTTPException(status_code=400, detail=f"文件大小不能超过 {max_bytes // 1024 // 1024}MB")
|
||||
out.write(chunk)
|
||||
final_path, file_url, file_name = build_target(scene, resource_type, admin_id, original_file_name)
|
||||
final_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(temp_path, final_path)
|
||||
temp_path = ""
|
||||
return StoredAdminUploadFile(
|
||||
storage_path=str(final_path),
|
||||
file_url=file_url,
|
||||
file_name=file_name,
|
||||
original_file_name=original_file_name,
|
||||
file_size_bytes=total,
|
||||
)
|
||||
except Exception:
|
||||
if temp_path:
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
if final_path and final_path.exists():
|
||||
try:
|
||||
final_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
@@ -66,6 +66,7 @@ async def calc_video_credits(
|
||||
resolution: str,
|
||||
engine_id: str | None = None,
|
||||
input_video_duration: float | None = None,
|
||||
input_image_count: int | None = None,
|
||||
) -> float:
|
||||
"""Calculate video credits using CreditRatio table, with fallback to hardcoded.
|
||||
|
||||
@@ -75,6 +76,7 @@ async def calc_video_credits(
|
||||
3. 原硬编码默认算法。
|
||||
|
||||
input_video_duration: 用户上传的参考视频总时长(秒),不为空时额外计费
|
||||
input_image_count: 用户上传的参考图片数量,不为空时额外计费
|
||||
"""
|
||||
if not engine_id:
|
||||
video_engines_result = await db.execute(
|
||||
@@ -97,6 +99,11 @@ async def calc_video_credits(
|
||||
ratio.input_video_base_credits + ratio.input_video_per_second_credits * input_video_duration
|
||||
) * ratio.input_video_ratio
|
||||
base_cost += input_video_cost
|
||||
if input_image_count and input_image_count > 0:
|
||||
input_image_cost = (
|
||||
ratio.input_image_base_credits + ratio.input_image_per_image_credits * input_image_count
|
||||
) * ratio.input_image_ratio
|
||||
base_cost += input_image_cost
|
||||
return round(base_cost, 2)
|
||||
|
||||
base = 60.0
|
||||
@@ -105,6 +112,8 @@ async def calc_video_credits(
|
||||
total = (base + duration_cost) * multiplier
|
||||
if input_video_duration and input_video_duration > 0:
|
||||
total += input_video_duration * 0.5 * multiplier
|
||||
if input_image_count and input_image_count > 0:
|
||||
total += input_image_count * 0.5 * multiplier
|
||||
return round(total, 2)
|
||||
|
||||
|
||||
@@ -120,6 +129,7 @@ async def calc_image_credits(
|
||||
db: AsyncSession,
|
||||
image_size: str,
|
||||
engine_id: str | None = None,
|
||||
input_image_count: int | None = None,
|
||||
) -> float:
|
||||
"""Calculate image credits using CreditRatio table, with fallback to hardcoded.
|
||||
|
||||
@@ -127,6 +137,8 @@ async def calc_image_credits(
|
||||
1. gen_type=image + engine_id + image_size 精确规则;
|
||||
2. gen_type=image + image_size 下 base_credits/per_second_credits 最高规则;
|
||||
3. 原硬编码默认算法。
|
||||
|
||||
input_image_count: 用户上传的参考图片数量,不为空时额外计费
|
||||
"""
|
||||
# 如果engine_id为空,默认查询权重最高的图片引擎积分规则
|
||||
if not engine_id:
|
||||
@@ -144,12 +156,21 @@ async def calc_image_credits(
|
||||
engine_id=engine_id,
|
||||
)
|
||||
if ratio:
|
||||
return round(ratio.base_credits * ratio.ratio, 2)
|
||||
base_cost = ratio.base_credits * ratio.ratio
|
||||
if input_image_count and input_image_count > 0:
|
||||
input_image_cost = (
|
||||
ratio.input_image_base_credits + ratio.input_image_per_image_credits * input_image_count
|
||||
) * ratio.input_image_ratio
|
||||
base_cost += input_image_cost
|
||||
return round(base_cost, 2)
|
||||
|
||||
# Fallback
|
||||
multiplier = {"4K": 2.0, "2K": 1.0}.get(image_size, 1.0)
|
||||
base_cost = 4.0
|
||||
return round(base_cost * multiplier, 2)
|
||||
total = base_cost * multiplier
|
||||
if input_image_count and input_image_count > 0:
|
||||
total += input_image_count * 0.5 * multiplier
|
||||
return round(total, 2)
|
||||
|
||||
|
||||
async def _get_existing_credit_record_by_biz_key(
|
||||
|
||||
@@ -770,6 +770,7 @@ async def list_generation_record_history_grouped_days(
|
||||
gen_type: str,
|
||||
page: int,
|
||||
page_size: int,
|
||||
keyword: str | None = None,
|
||||
):
|
||||
"""
|
||||
按生成日期倒序返回旧 generation_records 历史记录分组。
|
||||
@@ -783,6 +784,8 @@ async def list_generation_record_history_grouped_days(
|
||||
page_size = min(max(page_size, 1), HISTORY_DAY_PAGE_SIZE_MAX)
|
||||
|
||||
filters = _generation_record_history_base_filters(user_id, gen_type)
|
||||
if keyword and keyword.strip():
|
||||
filters.append(GenerationRecord.original_prompt.ilike(f"%{keyword.strip()}%"))
|
||||
day_expr = func.date(GenerationRecord.generated_at).label("generated_date")
|
||||
|
||||
days_subquery = (
|
||||
@@ -809,22 +812,48 @@ async def list_generation_record_history_grouped_days(
|
||||
)
|
||||
day_rows = day_rows_result.all()
|
||||
|
||||
if not day_rows:
|
||||
return {
|
||||
"total_days": int(total_days or 0),
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"groups": [],
|
||||
}
|
||||
|
||||
day_list = [row[0] for row in day_rows]
|
||||
day_total_map = {row[0]: row[1] for row in day_rows}
|
||||
|
||||
min_day = min(day_list)
|
||||
max_day = max(day_list)
|
||||
|
||||
all_rows_result = await db.execute(
|
||||
select(GenerationRecord, Project.name.label("project_name"))
|
||||
.outerjoin(Project, (GenerationRecord.project_id == Project.id) & (Project.deleted_at.is_(None)))
|
||||
.where(
|
||||
*filters,
|
||||
func.date(GenerationRecord.generated_at).between(min_day, max_day),
|
||||
)
|
||||
.order_by(GenerationRecord.generated_at.desc(), GenerationRecord.created_at.desc())
|
||||
)
|
||||
all_rows = all_rows_result.all()
|
||||
|
||||
rows_by_day: dict[str, list[tuple[GenerationRecord, str]]] = {}
|
||||
for record, project_name in all_rows:
|
||||
if record.generated_at is None:
|
||||
continue
|
||||
day_key = record.generated_at.date()
|
||||
if day_key not in rows_by_day:
|
||||
rows_by_day[day_key] = []
|
||||
rows_by_day[day_key].append((record, project_name))
|
||||
|
||||
raw_groups = []
|
||||
all_record_ids: list[str] = []
|
||||
for generated_day, day_total in day_rows:
|
||||
item_result = await db.execute(
|
||||
select(GenerationRecord, Project.name.label("project_name"))
|
||||
.outerjoin(Project, (GenerationRecord.project_id == Project.id) & (Project.deleted_at.is_(None)))
|
||||
.where(
|
||||
*filters,
|
||||
func.date(GenerationRecord.generated_at) == generated_day,
|
||||
)
|
||||
.order_by(GenerationRecord.generated_at.desc(), GenerationRecord.created_at.desc())
|
||||
.limit(HISTORY_GROUP_ITEM_LIMIT)
|
||||
)
|
||||
rows = item_result.all()
|
||||
raw_groups.append((generated_day, day_total, rows))
|
||||
all_record_ids.extend(record.id for record, _project_name in rows)
|
||||
for generated_day in day_list:
|
||||
day_rows_list = rows_by_day.get(generated_day, [])
|
||||
limited_rows = day_rows_list[:HISTORY_GROUP_ITEM_LIMIT]
|
||||
day_total = day_total_map.get(generated_day, 0)
|
||||
raw_groups.append((generated_day, day_total, limited_rows))
|
||||
all_record_ids.extend(record.id for record, _project_name in limited_rows)
|
||||
|
||||
resource_info_map = await batch_get_generated_resource_info_map(
|
||||
db,
|
||||
@@ -868,6 +897,7 @@ async def list_generation_record_history_day_items(
|
||||
generated_date: str,
|
||||
page: int,
|
||||
page_size: int,
|
||||
keyword: str | None = None,
|
||||
):
|
||||
"""
|
||||
获取旧 generation_records 指定生成日期下的历史记录分页。
|
||||
@@ -878,6 +908,8 @@ async def list_generation_record_history_day_items(
|
||||
page_size = min(max(page_size, 1), 100)
|
||||
|
||||
filters = _generation_record_history_base_filters(user_id, gen_type)
|
||||
if keyword and keyword.strip():
|
||||
filters.append(GenerationRecord.original_prompt.ilike(f"%{keyword.strip()}%"))
|
||||
day_expr = func.date(GenerationRecord.generated_at)
|
||||
|
||||
total = (
|
||||
@@ -935,6 +967,7 @@ async def list_generation_history_grouped_days(
|
||||
page: int,
|
||||
page_size: int,
|
||||
history_source: str | None = None,
|
||||
keyword: str | None = None,
|
||||
):
|
||||
"""
|
||||
按生成日期倒序返回历史记录分组。
|
||||
@@ -952,6 +985,7 @@ async def list_generation_history_grouped_days(
|
||||
gen_type=gen_type,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
keyword=keyword,
|
||||
)
|
||||
|
||||
gen_type = _normalize_history_gen_type(gen_type)
|
||||
@@ -959,6 +993,8 @@ async def list_generation_history_grouped_days(
|
||||
page_size = min(max(page_size, 1), HISTORY_DAY_PAGE_SIZE_MAX)
|
||||
|
||||
filters = _history_base_filters(user_id, gen_type, source)
|
||||
if keyword and keyword.strip():
|
||||
filters.append(ChatGenerationTask.original_prompt.ilike(f"%{keyword.strip()}%"))
|
||||
day_expr = func.date(ChatGenerationTask.generated_at).label("generated_date")
|
||||
|
||||
days_subquery = (
|
||||
@@ -985,21 +1021,47 @@ async def list_generation_history_grouped_days(
|
||||
)
|
||||
day_rows = day_rows_result.all()
|
||||
|
||||
if not day_rows:
|
||||
return {
|
||||
"total_days": int(total_days or 0),
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"groups": [],
|
||||
}
|
||||
|
||||
day_list = [row[0] for row in day_rows]
|
||||
day_total_map = {row[0]: row[1] for row in day_rows}
|
||||
|
||||
min_day = min(day_list)
|
||||
max_day = max(day_list)
|
||||
|
||||
all_tasks_result = await db.execute(
|
||||
select(ChatGenerationTask)
|
||||
.where(
|
||||
*filters,
|
||||
func.date(ChatGenerationTask.generated_at).between(min_day, max_day),
|
||||
)
|
||||
.order_by(ChatGenerationTask.generated_at.desc(), ChatGenerationTask.created_at.desc())
|
||||
)
|
||||
all_tasks = list(all_tasks_result.scalars().all())
|
||||
|
||||
tasks_by_day: dict[str, list[ChatGenerationTask]] = {}
|
||||
for task in all_tasks:
|
||||
if task.generated_at is None:
|
||||
continue
|
||||
day_key = task.generated_at.date()
|
||||
if day_key not in tasks_by_day:
|
||||
tasks_by_day[day_key] = []
|
||||
tasks_by_day[day_key].append(task)
|
||||
|
||||
raw_groups = []
|
||||
all_task_ids: list[str] = []
|
||||
for generated_day, day_total in day_rows:
|
||||
item_result = await db.execute(
|
||||
select(ChatGenerationTask)
|
||||
.where(
|
||||
*filters,
|
||||
func.date(ChatGenerationTask.generated_at) == generated_day,
|
||||
)
|
||||
.order_by(ChatGenerationTask.generated_at.desc(), ChatGenerationTask.created_at.desc())
|
||||
.limit(HISTORY_GROUP_ITEM_LIMIT)
|
||||
)
|
||||
tasks = list(item_result.scalars().all())
|
||||
raw_groups.append((generated_day, day_total, tasks))
|
||||
all_task_ids.extend(task.id for task in tasks)
|
||||
for generated_day in day_list:
|
||||
day_tasks = tasks_by_day.get(generated_day, [])
|
||||
limited_tasks = day_tasks[:HISTORY_GROUP_ITEM_LIMIT]
|
||||
day_total = day_total_map.get(generated_day, 0)
|
||||
raw_groups.append((generated_day, day_total, limited_tasks))
|
||||
all_task_ids.extend(task.id for task in limited_tasks)
|
||||
|
||||
resource_info_map = await batch_get_generated_resource_info_map(
|
||||
db,
|
||||
@@ -1012,8 +1074,8 @@ async def list_generation_history_grouped_days(
|
||||
source=source,
|
||||
chat_task_ids=all_task_ids,
|
||||
)
|
||||
all_tasks = [task for _generated_day, _day_total, tasks in raw_groups for task in tasks]
|
||||
reference_display_map = await _resolve_task_reference_display_map(db, all_tasks, user_id=user_id)
|
||||
all_tasks_for_refs = [task for _generated_day, _day_total, tasks in raw_groups for task in tasks]
|
||||
reference_display_map = await _resolve_task_reference_display_map(db, all_tasks_for_refs, user_id=user_id)
|
||||
|
||||
groups = [
|
||||
{
|
||||
@@ -1049,6 +1111,7 @@ async def list_generation_history_day_items(
|
||||
page: int,
|
||||
page_size: int,
|
||||
history_source: str | None = None,
|
||||
keyword: str | None = None,
|
||||
):
|
||||
"""
|
||||
获取指定生成日期下的历史记录分页。
|
||||
@@ -1065,6 +1128,7 @@ async def list_generation_history_day_items(
|
||||
generated_date=generated_date,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
keyword=keyword,
|
||||
)
|
||||
|
||||
gen_type = _normalize_history_gen_type(gen_type)
|
||||
@@ -1073,6 +1137,8 @@ async def list_generation_history_day_items(
|
||||
page_size = min(max(page_size, 1), 100)
|
||||
|
||||
filters = _history_base_filters(user_id, gen_type, source)
|
||||
if keyword and keyword.strip():
|
||||
filters.append(ChatGenerationTask.original_prompt.ilike(f"%{keyword.strip()}%"))
|
||||
day_expr = func.date(ChatGenerationTask.generated_at)
|
||||
|
||||
total = (
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.enums.home_material import (
|
||||
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
||||
from app.schemas.home_material import (
|
||||
HomeMaterialAssetOut,
|
||||
HomeMaterialMediaReference,
|
||||
HomeMaterialCategoryOut,
|
||||
HomeMaterialPublicAssetOut,
|
||||
HomeMaterialPublicCategoryGroupOut,
|
||||
@@ -39,6 +40,24 @@ def _load_json(value: str | None) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _load_media_references(value: str | None) -> list[HomeMaterialMediaReference]:
|
||||
if not value:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(value)
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
refs: list[HomeMaterialMediaReference] = []
|
||||
for item in data:
|
||||
try:
|
||||
refs.append(HomeMaterialMediaReference(**item))
|
||||
except Exception:
|
||||
continue
|
||||
return refs
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
class HomeMaterialQueryService:
|
||||
"""首页素材高性能查询组装层:列表查询 → ID 去重 → 批量查询 → map 组装。"""
|
||||
|
||||
@@ -152,6 +171,8 @@ class HomeMaterialQueryService:
|
||||
watermark_id=asset.watermark_id,
|
||||
watermark_name=watermark.name if watermark else None,
|
||||
watermark_config=_load_json(asset.watermark_config_json),
|
||||
generation_prompt=asset.generation_prompt,
|
||||
media_references=_load_media_references(asset.media_references_json),
|
||||
width=asset.width,
|
||||
height=asset.height,
|
||||
duration_seconds=asset.duration_seconds,
|
||||
@@ -340,6 +361,8 @@ class HomeMaterialQueryService:
|
||||
width=a.width,
|
||||
height=a.height,
|
||||
duration_seconds=a.duration_seconds,
|
||||
generation_prompt=a.generation_prompt,
|
||||
media_references=_load_media_references(a.media_references_json),
|
||||
sort_order=a.sort_order,
|
||||
)
|
||||
for a in assets
|
||||
@@ -403,6 +426,8 @@ class HomeMaterialQueryService:
|
||||
width=asset.width,
|
||||
height=asset.height,
|
||||
duration_seconds=asset.duration_seconds,
|
||||
generation_prompt=asset.generation_prompt,
|
||||
media_references=_load_media_references(asset.media_references_json),
|
||||
sort_order=asset.sort_order,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -12,14 +12,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.enums.home_material import (
|
||||
HOME_MATERIAL_DEFAULT_CONFIG,
|
||||
HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN,
|
||||
HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN,
|
||||
HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT,
|
||||
HomeMaterialAssetStatus,
|
||||
HomeMaterialConfigKeyEnum,
|
||||
HomeMaterialLogEventEnum,
|
||||
HomeMaterialMediaType,
|
||||
HomeMaterialPublicResponseMode,
|
||||
HomeMaterialWatermarkPosition,
|
||||
HomeMaterialWatermarkSizeMode,
|
||||
HomeMaterialWatermarkType,
|
||||
)
|
||||
from app.enums.common import LogEventStatusEnum, LogSourceEnum
|
||||
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceSourceModelEnum
|
||||
from app.models.base import async_session
|
||||
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
||||
from app.models.system_config import SystemConfig
|
||||
@@ -36,6 +42,7 @@ from app.schemas.home_material import (
|
||||
HomeMaterialConfigUpdate,
|
||||
HomeMaterialPublicCategoryListOut,
|
||||
HomeMaterialPublicFlatOut,
|
||||
HomeMaterialMediaReference,
|
||||
HomeMaterialPublicGroupedOut,
|
||||
HomeMaterialRegenerateWatermarkRequest,
|
||||
HomeMaterialTextWatermarkPreviewRequest,
|
||||
@@ -49,6 +56,8 @@ from app.schemas.home_material import (
|
||||
from app.services.home_material.query import query_service
|
||||
from app.services.home_material.storage import storage_service
|
||||
from app.services.home_material.watermark_processor import watermark_processor
|
||||
from app.services.operation_log_service import build_exception_detail, log_operation_event
|
||||
from app.services.upload_resource.bind_service import bind_upload_resources
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
|
||||
@@ -66,11 +75,99 @@ def _json_loads(value: str | None) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _json_loads_list(value: str | None) -> list[Any]:
|
||||
if not value:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(value)
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception as exc:
|
||||
log_operation_event(
|
||||
domain="home_material",
|
||||
module="home_material_asset",
|
||||
event_type=HomeMaterialLogEventEnum.MEDIA_REFERENCES_PARSE_FAILED.value,
|
||||
event_status=LogEventStatusEnum.WARNING.value,
|
||||
source=LogSourceEnum.SERVICE.value,
|
||||
detail={"raw_prefix": value[:500]},
|
||||
error=str(exc),
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def _clean_title(value: str | None) -> str | None:
|
||||
value = (value or "").strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _clean_generation_prompt(value: str | None) -> str | None:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) > HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"生成提词不能超过 {HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN} 个字符")
|
||||
return text
|
||||
|
||||
|
||||
def _media_references_from_json(value: str | None) -> list[HomeMaterialMediaReference]:
|
||||
if not value or not value.strip():
|
||||
return []
|
||||
if len(value) > HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件JSON不能超过 {HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN} 个字符")
|
||||
try:
|
||||
raw = json.loads(value)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件JSON格式错误") from exc
|
||||
if raw is None:
|
||||
return []
|
||||
if not isinstance(raw, list):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件必须是数组格式")
|
||||
return _normalize_media_references(raw)
|
||||
|
||||
|
||||
def _normalize_media_references(value: list[Any] | None) -> list[HomeMaterialMediaReference]:
|
||||
if not value:
|
||||
return []
|
||||
if len(value) > HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件最多 {HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT} 个")
|
||||
refs: list[HomeMaterialMediaReference] = []
|
||||
for item in value:
|
||||
try:
|
||||
ref = item if isinstance(item, HomeMaterialMediaReference) else HomeMaterialMediaReference(**item)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件格式错误:{exc}") from exc
|
||||
refs.append(ref)
|
||||
return refs
|
||||
|
||||
|
||||
def _dump_media_references_json(refs: list[HomeMaterialMediaReference] | None) -> str | None:
|
||||
items = [r.model_dump(mode="json", exclude_none=True) for r in (refs or [])]
|
||||
if not items:
|
||||
return None
|
||||
text = _json_dumps(items)
|
||||
if len(text) > HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"附件JSON不能超过 {HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN} 个字符")
|
||||
return text
|
||||
|
||||
|
||||
def _media_references_out(value: str | None) -> list[HomeMaterialMediaReference]:
|
||||
return _normalize_media_references(_json_loads_list(value))
|
||||
|
||||
|
||||
def _media_reference_resource_ids(refs: list[HomeMaterialMediaReference]) -> list[str]:
|
||||
return [str(r.upload_resource_id).strip() for r in refs if r.upload_resource_id and str(r.upload_resource_id).strip()]
|
||||
|
||||
|
||||
def _media_reference_urls(refs: list[HomeMaterialMediaReference]) -> list[str]:
|
||||
return [str(r.url).strip() for r in refs if r.url and str(r.url).strip()]
|
||||
|
||||
|
||||
def _media_reference_type_counts(refs: list[HomeMaterialMediaReference]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for ref in refs:
|
||||
counts[ref.type] = counts.get(ref.type, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def _normalize_watermark_config_dict(value: dict[str, Any] | None, fallback_watermark_id: str | None = None) -> dict[str, Any]:
|
||||
"""兼容旧水印配置。旧数据没有 watermark_type 时按 image 处理。"""
|
||||
data = dict(value or {})
|
||||
@@ -108,6 +205,8 @@ def _asset_snapshot(asset: HomeMaterialAsset | None) -> dict[str, Any] | None:
|
||||
"cover_url": asset.cover_url,
|
||||
"watermark_id": asset.watermark_id,
|
||||
"watermark_config": _json_loads(asset.watermark_config_json),
|
||||
"generation_prompt": asset.generation_prompt,
|
||||
"media_references_count": len(_media_references_out(asset.media_references_json)),
|
||||
"is_active": asset.is_active,
|
||||
"sort_order": asset.sort_order,
|
||||
"deleted_at": asset.deleted_at,
|
||||
@@ -142,6 +241,31 @@ def _watermark_snapshot(watermark: HomeMaterialWatermark | None) -> dict[str, An
|
||||
}
|
||||
|
||||
|
||||
|
||||
def _log_generation_config_event(
|
||||
event_type: str,
|
||||
*,
|
||||
admin_id: str | None,
|
||||
asset_id: str | None = None,
|
||||
event_status: str = LogEventStatusEnum.SUCCESS.value,
|
||||
message: str | None = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
log_operation_event(
|
||||
domain="home_material",
|
||||
module="home_material_asset",
|
||||
event_type=event_type,
|
||||
event_status=event_status,
|
||||
source=LogSourceEnum.API.value,
|
||||
user_id=admin_id,
|
||||
asset_id=asset_id,
|
||||
message=message,
|
||||
detail=detail or {},
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
async def _flush_refresh(db: AsyncSession, obj: Any) -> None:
|
||||
"""
|
||||
写入后立即返回 ORM 对象前必须显式刷新。
|
||||
@@ -372,10 +496,24 @@ class HomeMaterialService:
|
||||
watermark_config: HomeMaterialWatermarkConfig,
|
||||
is_active: bool,
|
||||
sort_order: int,
|
||||
generation_prompt: str | None = None,
|
||||
media_references_json: str | None = None,
|
||||
admin_id: str | None,
|
||||
) -> tuple[HomeMaterialUploadResultOut, dict[str, Any]]:
|
||||
await self._get_category(db, category_id, active_only=False)
|
||||
clean_title = _clean_title(title)
|
||||
clean_prompt = _clean_generation_prompt(generation_prompt)
|
||||
refs = _media_references_from_json(media_references_json)
|
||||
refs_json = _dump_media_references_json(refs)
|
||||
_log_generation_config_event(
|
||||
HomeMaterialLogEventEnum.GENERATION_CONFIG_VALIDATE_SUCCESS.value,
|
||||
admin_id=admin_id,
|
||||
detail={
|
||||
"generation_prompt_length": len(clean_prompt or ""),
|
||||
"media_references_count": len(refs),
|
||||
"media_reference_types": _media_reference_type_counts(refs),
|
||||
},
|
||||
)
|
||||
watermark_type = watermark_config.watermark_type
|
||||
final_watermark_id: str | None = None
|
||||
if watermark_type == HomeMaterialWatermarkType.IMAGE:
|
||||
@@ -415,6 +553,8 @@ class HomeMaterialService:
|
||||
original_storage_path=stored.storage_path,
|
||||
watermark_id=final_watermark_id,
|
||||
watermark_config_json=_json_dumps(cfg.model_dump(mode="json")),
|
||||
generation_prompt=clean_prompt,
|
||||
media_references_json=refs_json,
|
||||
status=HomeMaterialAssetStatus.PROCESSING.value,
|
||||
width=probe.width,
|
||||
height=probe.height,
|
||||
@@ -427,7 +567,10 @@ class HomeMaterialService:
|
||||
)
|
||||
db.add(asset)
|
||||
await _flush_refresh(db, asset)
|
||||
return self._upload_result(asset, message="素材已上传,水印处理中"), _asset_snapshot(asset) or {}
|
||||
bind_stats = await self._bind_media_reference_resources(db, asset_id=asset.id, admin_id=admin_id, refs=refs)
|
||||
after = _asset_snapshot(asset) or {}
|
||||
after["media_reference_bind_stats"] = bind_stats
|
||||
return self._upload_result(asset, message="素材已上传,水印处理中"), after
|
||||
|
||||
async def list_assets(
|
||||
self,
|
||||
@@ -466,15 +609,67 @@ class HomeMaterialService:
|
||||
asset = await self._get_asset(db, asset_id)
|
||||
await self._get_category(db, req.category_id)
|
||||
before = _asset_snapshot(asset) or {}
|
||||
asset.category_id = req.category_id
|
||||
asset.title = req.title
|
||||
asset.is_active = req.is_active
|
||||
asset.sort_order = req.sort_order
|
||||
asset.updated_by = admin_id
|
||||
db.add(asset)
|
||||
await _flush_refresh(db, asset)
|
||||
after = _asset_snapshot(asset) or {}
|
||||
return await self.get_asset_detail(db, asset_id), before, after
|
||||
try:
|
||||
_log_generation_config_event(
|
||||
HomeMaterialLogEventEnum.GENERATION_CONFIG_VALIDATE_STARTED.value,
|
||||
admin_id=admin_id,
|
||||
asset_id=asset_id,
|
||||
event_status=LogEventStatusEnum.STARTED.value,
|
||||
)
|
||||
clean_prompt = _clean_generation_prompt(req.generation_prompt)
|
||||
refs = _normalize_media_references(req.media_references)
|
||||
refs_json = _dump_media_references_json(refs)
|
||||
_log_generation_config_event(
|
||||
HomeMaterialLogEventEnum.GENERATION_CONFIG_VALIDATE_SUCCESS.value,
|
||||
admin_id=admin_id,
|
||||
asset_id=asset_id,
|
||||
detail={
|
||||
"generation_prompt_length": len(clean_prompt or ""),
|
||||
"media_references_count": len(refs),
|
||||
"media_reference_types": _media_reference_type_counts(refs),
|
||||
},
|
||||
)
|
||||
_log_generation_config_event(
|
||||
HomeMaterialLogEventEnum.GENERATION_CONFIG_UPDATE_STARTED.value,
|
||||
admin_id=admin_id,
|
||||
asset_id=asset_id,
|
||||
event_status=LogEventStatusEnum.STARTED.value,
|
||||
)
|
||||
asset.category_id = req.category_id
|
||||
asset.title = req.title
|
||||
asset.is_active = req.is_active
|
||||
asset.sort_order = req.sort_order
|
||||
asset.generation_prompt = clean_prompt
|
||||
asset.media_references_json = refs_json
|
||||
asset.updated_by = admin_id
|
||||
db.add(asset)
|
||||
await _flush_refresh(db, asset)
|
||||
bind_stats = await self._bind_media_reference_resources(db, asset_id=asset.id, admin_id=admin_id, refs=refs)
|
||||
after = _asset_snapshot(asset) or {}
|
||||
after["media_reference_bind_stats"] = bind_stats
|
||||
_log_generation_config_event(
|
||||
HomeMaterialLogEventEnum.GENERATION_CONFIG_UPDATE_SUCCESS.value,
|
||||
admin_id=admin_id,
|
||||
asset_id=asset_id,
|
||||
detail={
|
||||
"changed_fields": [k for k in ("generation_prompt", "media_references_count") if before.get(k) != after.get(k)],
|
||||
"generation_prompt_length": len(clean_prompt or ""),
|
||||
"media_references_count": len(refs),
|
||||
"media_reference_types": _media_reference_type_counts(refs),
|
||||
"bind_stats": bind_stats,
|
||||
},
|
||||
)
|
||||
return await self.get_asset_detail(db, asset_id), before, after
|
||||
except Exception as exc:
|
||||
_log_generation_config_event(
|
||||
HomeMaterialLogEventEnum.GENERATION_CONFIG_UPDATE_FAILED.value,
|
||||
admin_id=admin_id,
|
||||
asset_id=asset_id,
|
||||
event_status=LogEventStatusEnum.FAILED.value,
|
||||
detail=build_exception_detail(exc, {"before": before}),
|
||||
error=str(exc),
|
||||
)
|
||||
raise
|
||||
|
||||
async def prepare_regenerate(
|
||||
self,
|
||||
@@ -509,6 +704,28 @@ class HomeMaterialService:
|
||||
db.add(asset)
|
||||
return asset, before
|
||||
|
||||
|
||||
async def _bind_media_reference_resources(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
asset_id: str,
|
||||
admin_id: str | None,
|
||||
refs: list[HomeMaterialMediaReference],
|
||||
) -> dict[str, int]:
|
||||
if not admin_id or not refs:
|
||||
return {"matched": 0, "bound": 0, "skipped": 0, "conflict": 0}
|
||||
return await bind_upload_resources(
|
||||
db,
|
||||
user_id=admin_id,
|
||||
module=UploadResourceModuleEnum.HOME_MATERIAL.value,
|
||||
source_model=UploadResourceSourceModelEnum.HOME_MATERIAL_ASSET.value,
|
||||
source_id=asset_id,
|
||||
resource_ids=_media_reference_resource_ids(refs),
|
||||
urls=_media_reference_urls(refs),
|
||||
allow_common_migrate=False,
|
||||
)
|
||||
|
||||
async def get_asset_status(self, db: AsyncSession, asset_id: str) -> HomeMaterialAssetStatusOut:
|
||||
asset = await self._get_asset(db, asset_id)
|
||||
return HomeMaterialAssetStatusOut(
|
||||
@@ -541,6 +758,8 @@ class HomeMaterialService:
|
||||
watermarked_url=asset.watermarked_url,
|
||||
cover_url=asset.cover_url,
|
||||
watermark_config=_json_loads(asset.watermark_config_json),
|
||||
generation_prompt=asset.generation_prompt,
|
||||
media_references=_media_references_out(asset.media_references_json),
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ def _lease_seconds() -> int:
|
||||
|
||||
|
||||
def _shot_analysis_lease_seconds() -> int:
|
||||
timeout = max(1, int(getattr(settings, "SHOT_ANALYSIS_TIMEOUT_SECONDS", 600) or 600))
|
||||
timeout = max(1, int(getattr(settings, "SHOT_ANALYSIS_TIMEOUT_SECONDS", 3600) or 3600))
|
||||
return max(_lease_seconds(), timeout + 120)
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -31,11 +31,22 @@ from app.enums.private_portrait import (
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
PrivatePortraitValidateSessionStatus,
|
||||
)
|
||||
from app.enums.upload_resource import (
|
||||
UploadResourceBindStatusEnum,
|
||||
UploadResourceDeletePolicyEnum,
|
||||
UploadResourceModuleEnum,
|
||||
UploadResourceSourceModelEnum,
|
||||
UploadResourceTypeEnum,
|
||||
)
|
||||
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject, PrivatePortraitValidateSession
|
||||
from app.models.upload_resource import UploadResource
|
||||
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, PrivatePortraitSelectableAssetOut, PrivatePortraitValidateSessionOut
|
||||
from app.services.operation_log_service import log_operation_error, log_operation_event
|
||||
from app.services.private_portrait.ark_client import ArkPrivateAssetClient
|
||||
from app.services.private_portrait.project_service import get_user_project, refresh_project_counters
|
||||
from app.services.private_portrait.upload_service import private_portrait_upload_module
|
||||
from app.services.upload_resource import bind_upload_resources, release_upload_resources_by_source
|
||||
from app.services.upload_resource.path_resolver import upload_url_to_storage_path
|
||||
from app.services.private_portrait.quota_service import (
|
||||
count_user_counting_assets,
|
||||
ensure_private_portrait_asset_quota_available,
|
||||
@@ -133,6 +144,78 @@ def _assert_private_asset_video_duration(payload: PrivatePortraitAssetCreate) ->
|
||||
raise HTTPException(status_code=400, detail=f"视频素材最长不能超过 {PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS} 秒")
|
||||
|
||||
|
||||
def _resource_type_for_asset_type(asset_type: str) -> str:
|
||||
if asset_type == PrivatePortraitAssetType.VIDEO.value:
|
||||
return UploadResourceTypeEnum.VIDEO.value
|
||||
return UploadResourceTypeEnum.IMAGE.value
|
||||
|
||||
|
||||
def _safe_set_payload_attr(payload: PrivatePortraitAssetCreate, name: str, value: Any) -> None:
|
||||
if value is None:
|
||||
return
|
||||
try:
|
||||
setattr(payload, name, value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _resolve_upload_resource_for_asset(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
payload: PrivatePortraitAssetCreate,
|
||||
module: str,
|
||||
) -> UploadResource | None:
|
||||
"""定位并校验待绑定的 UploadResource。
|
||||
|
||||
新客户端必须传 upload_resource_id;旧客户端没传时按 url 反查 storage_path 兼容。
|
||||
不通过 relationship 懒加载,全部按 ID/路径批查,避免 commit 后 ORM 失效风险。
|
||||
"""
|
||||
resource_id = str(payload.upload_resource_id or "").strip() or None
|
||||
storage_path = upload_url_to_storage_path(payload.url)
|
||||
if not resource_id and not storage_path:
|
||||
return None
|
||||
|
||||
filters = [
|
||||
UploadResource.user_id == user_id,
|
||||
UploadResource.deleted_at.is_(None),
|
||||
]
|
||||
if resource_id and storage_path:
|
||||
filters.append(or_(UploadResource.id == resource_id, UploadResource.storage_path == storage_path))
|
||||
elif resource_id:
|
||||
filters.append(UploadResource.id == resource_id)
|
||||
else:
|
||||
filters.append(UploadResource.storage_path == storage_path)
|
||||
|
||||
result = await db.execute(select(UploadResource).where(*filters).with_for_update().limit(1))
|
||||
resource = result.scalar_one_or_none()
|
||||
if not resource:
|
||||
if resource_id:
|
||||
raise HTTPException(status_code=404, detail="上传资源不存在或不属于当前用户")
|
||||
return None
|
||||
|
||||
if resource.bind_status != UploadResourceBindStatusEnum.PENDING.value or resource.source_id or resource.source_model:
|
||||
raise HTTPException(status_code=409, detail="上传资源已绑定其他素材,不能重复使用")
|
||||
if resource.delete_policy != UploadResourceDeletePolicyEnum.USER_DELETABLE.value:
|
||||
raise HTTPException(status_code=409, detail="上传资源当前不允许绑定私域素材")
|
||||
|
||||
expected_type = _resource_type_for_asset_type(payload.asset_type)
|
||||
if resource.resource_type != expected_type:
|
||||
raise HTTPException(status_code=400, detail="上传资源类型与素材类型不一致")
|
||||
|
||||
if resource.module not in {module, UploadResourceModuleEnum.COMMON.value}:
|
||||
raise HTTPException(status_code=409, detail="上传资源所属模块不匹配,请重新上传素材")
|
||||
|
||||
if payload.asset_type == PrivatePortraitAssetType.VIDEO.value and payload.video_duration is None and resource.duration_seconds is not None:
|
||||
_safe_set_payload_attr(payload, "video_duration", float(resource.duration_seconds))
|
||||
if payload.file_size is None and resource.file_size_bytes is not None:
|
||||
_safe_set_payload_attr(payload, "file_size", int(resource.file_size_bytes or 0))
|
||||
if payload.mime_type is None and resource.mime_type:
|
||||
_safe_set_payload_attr(payload, "mime_type", resource.mime_type)
|
||||
|
||||
return resource
|
||||
|
||||
|
||||
def validate_session_to_out(session: PrivatePortraitValidateSession, *, include_user: bool = False) -> PrivatePortraitValidateSessionOut:
|
||||
return PrivatePortraitValidateSessionOut(
|
||||
id=session.id,
|
||||
@@ -417,11 +500,14 @@ async def create_asset(
|
||||
library_type: str | None = None,
|
||||
) -> PrivatePortraitAsset:
|
||||
_assert_enabled_asset_type(payload.asset_type)
|
||||
_assert_private_asset_video_duration(payload)
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
|
||||
if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail="项目未激活,不能上传素材")
|
||||
|
||||
module = private_portrait_upload_module(project.library_type)
|
||||
upload_resource = await _resolve_upload_resource_for_asset(db, user_id=user_id, payload=payload, module=module)
|
||||
_assert_private_asset_video_duration(payload)
|
||||
|
||||
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)
|
||||
@@ -445,7 +531,7 @@ async def create_asset(
|
||||
)
|
||||
db.add(asset)
|
||||
await db.flush()
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"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})
|
||||
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, "upload_resource_id": upload_resource.id if upload_resource else payload.upload_resource_id})
|
||||
try:
|
||||
remote_resp = await ArkPrivateAssetClient().create_asset(project_name=project.remote_project_name, group_id=group.remote_group_id, url=public_url, asset_type=payload.asset_type, name=payload.name)
|
||||
remote_asset_id = remote_resp.get("Id") or remote_resp.get("AssetId") or remote_resp.get("assetId")
|
||||
@@ -456,6 +542,28 @@ async def create_asset(
|
||||
asset.status = PrivatePortraitAssetStatus.PROCESSING.value
|
||||
asset.next_poll_at = now + timedelta(seconds=_poll_interval_seconds(asset.asset_type))
|
||||
asset.raw_response_json = _json(remote_resp)
|
||||
bind_stats = await bind_upload_resources(
|
||||
db,
|
||||
user_id=user_id,
|
||||
module=module,
|
||||
source_model=UploadResourceSourceModelEnum.PRIVATE_PORTRAIT_ASSET.value,
|
||||
source_id=asset.id,
|
||||
resource_ids=[payload.upload_resource_id, upload_resource.id if upload_resource else None],
|
||||
urls=[payload.url],
|
||||
allow_common_migrate=True,
|
||||
)
|
||||
if bind_stats.get("bound"):
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_UPLOAD_BIND_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={"module": module, "upload_resource_id": payload.upload_resource_id, "bind_stats": bind_stats},
|
||||
)
|
||||
await refresh_project_counters(db, [project.id])
|
||||
await db.flush()
|
||||
await db.refresh(asset)
|
||||
@@ -465,7 +573,7 @@ async def create_asset(
|
||||
asset.status = PrivatePortraitAssetStatus.FAILED.value
|
||||
asset.error_message = _exception_message(exc)
|
||||
await db.flush()
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, exc=exc)
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, exc=exc, detail={"upload_resource_id": payload.upload_resource_id, "module": module})
|
||||
raise
|
||||
|
||||
|
||||
@@ -598,10 +706,19 @@ async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str, li
|
||||
asset.deleted_at = now
|
||||
asset.status = PrivatePortraitAssetStatus.LOCAL_DELETED.value
|
||||
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.PENDING.value
|
||||
module = private_portrait_upload_module(asset.library_type)
|
||||
upload_release = await release_upload_resources_by_source(
|
||||
db,
|
||||
source_model=UploadResourceSourceModelEnum.PRIVATE_PORTRAIT_ASSET.value,
|
||||
source_ids=[asset.id],
|
||||
module=module,
|
||||
)
|
||||
setattr(asset, "_pending_upload_resource_ids", list(upload_release.get("released_resource_ids") or []))
|
||||
setattr(asset, "_upload_resource_release", upload_release)
|
||||
await refresh_project_counters(db, [asset.project_id])
|
||||
await db.flush()
|
||||
await db.refresh(asset)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_LOCAL.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type})
|
||||
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, "upload_resource_release": {k: v for k, v in getattr(asset, "_upload_resource_release", {}).items() if k != "released_resource_ids"}, "pending_upload_resource_count": len(getattr(asset, "_pending_upload_resource_ids", []))})
|
||||
return asset
|
||||
|
||||
|
||||
@@ -617,7 +734,7 @@ async def delete_asset_remote(db: AsyncSession, *, asset_id: str) -> None:
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SKIPPED.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, message="远程删除跳过:素材没有 remote_asset_id")
|
||||
return
|
||||
now = datetime.now(timezone.utc)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type})
|
||||
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, "upload_resource_release": {k: v for k, v in getattr(asset, "_upload_resource_release", {}).items() if k != "released_resource_ids"}, "pending_upload_resource_count": len(getattr(asset, "_pending_upload_resource_ids", []))})
|
||||
try:
|
||||
await ArkPrivateAssetClient(for_celery=True).delete_asset(project_name=asset.remote_project_name, asset_id=asset.remote_asset_id)
|
||||
asset.status = PrivatePortraitAssetStatus.REMOTE_DELETED.value
|
||||
|
||||
@@ -19,9 +19,12 @@ from app.enums.private_portrait import (
|
||||
PrivatePortraitProjectStatus,
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
)
|
||||
from app.enums.upload_resource import UploadResourceSourceModelEnum
|
||||
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject
|
||||
from app.schemas.private_portrait import PrivatePortraitProjectCreate, PrivatePortraitProjectOut, PrivatePortraitProjectUpdate
|
||||
from app.services.operation_log_service import log_operation_event
|
||||
from app.services.private_portrait.upload_service import private_portrait_upload_module
|
||||
from app.services.upload_resource import release_upload_resources_by_source
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
DOMAIN = "private_portrait"
|
||||
@@ -264,8 +267,29 @@ async def soft_delete_project(
|
||||
) -> PrivatePortraitProject:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
|
||||
now = datetime.now(timezone.utc)
|
||||
asset_id_rows = await db.execute(
|
||||
select(PrivatePortraitAsset.id)
|
||||
.where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.deleted_at.is_(None))
|
||||
)
|
||||
asset_ids = [row[0] for row in asset_id_rows.all()]
|
||||
module = private_portrait_upload_module(project.library_type)
|
||||
|
||||
project.deleted_at = now
|
||||
project.status = PrivatePortraitProjectStatus.DELETED.value
|
||||
upload_release = await release_upload_resources_by_source(
|
||||
db,
|
||||
source_model=UploadResourceSourceModelEnum.PRIVATE_PORTRAIT_ASSET.value,
|
||||
source_ids=asset_ids,
|
||||
module=module,
|
||||
) if asset_ids else {
|
||||
"matched": 0,
|
||||
"released": 0,
|
||||
"already_deleted": 0,
|
||||
"released_resource_ids": [],
|
||||
}
|
||||
setattr(project, "_pending_upload_resource_ids", list(upload_release.get("released_resource_ids") or []))
|
||||
setattr(project, "_upload_resource_release", upload_release)
|
||||
|
||||
await db.execute(
|
||||
update(PrivatePortraitAsset)
|
||||
.where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.deleted_at.is_(None))
|
||||
@@ -285,6 +309,6 @@ async def soft_delete_project(
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
message="本地软删私域人像素材项目",
|
||||
detail={"library_type": project.library_type, "remote_project_name": project.remote_project_name},
|
||||
detail={"library_type": project.library_type, "remote_project_name": project.remote_project_name, "asset_count": len(asset_ids), "upload_resource_release": {k: v for k, v in upload_release.items() if k != "released_resource_ids"}, "pending_upload_resource_count": len(getattr(project, "_pending_upload_resource_ids", []))},
|
||||
)
|
||||
return project
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.private_portrait import (
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
PrivatePortraitLibraryType,
|
||||
)
|
||||
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceTypeEnum
|
||||
from app.models.user import User
|
||||
from app.schemas.private_portrait import PrivatePortraitUploadOut
|
||||
from app.services.operation_log_service import log_operation_error, log_operation_event
|
||||
from app.services.upload_resource import upload_reference_file
|
||||
|
||||
DOMAIN = "private_portrait"
|
||||
PRIVATE_PORTRAIT_IMAGE_MAX_BYTES = 10 * 1024 * 1024
|
||||
PRIVATE_PORTRAIT_VIDEO_MAX_BYTES = 100 * 1024 * 1024
|
||||
|
||||
|
||||
def private_portrait_upload_module(library_type: str) -> str:
|
||||
if library_type == PrivatePortraitLibraryType.REAL_PERSON.value:
|
||||
return UploadResourceModuleEnum.PRIVATE_PORTRAIT_REAL.value
|
||||
if library_type == PrivatePortraitLibraryType.AIGC_VIRTUAL.value:
|
||||
return UploadResourceModuleEnum.PRIVATE_PORTRAIT_VIRTUAL.value
|
||||
raise HTTPException(status_code=400, detail="素材库类型不支持")
|
||||
|
||||
|
||||
async def upload_private_portrait_asset_file(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
file: UploadFile,
|
||||
current_user: User,
|
||||
library_type: str,
|
||||
resource_type: str,
|
||||
duration_seconds: float | None = None,
|
||||
) -> PrivatePortraitUploadOut:
|
||||
if resource_type not in {UploadResourceTypeEnum.IMAGE.value, UploadResourceTypeEnum.VIDEO.value}:
|
||||
raise HTTPException(status_code=400, detail="私域素材上传仅支持图片或视频")
|
||||
|
||||
module = private_portrait_upload_module(library_type)
|
||||
max_bytes = PRIVATE_PORTRAIT_VIDEO_MAX_BYTES if resource_type == UploadResourceTypeEnum.VIDEO.value else PRIVATE_PORTRAIT_IMAGE_MAX_BYTES
|
||||
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_UPLOAD_START.value,
|
||||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=current_user.id,
|
||||
detail={
|
||||
"module": module,
|
||||
"library_type": library_type,
|
||||
"resource_type": resource_type,
|
||||
"filename": file.filename,
|
||||
"content_type": file.content_type,
|
||||
"duration_seconds": duration_seconds,
|
||||
},
|
||||
)
|
||||
try:
|
||||
result = await upload_reference_file(
|
||||
db,
|
||||
file=file,
|
||||
current_user=current_user,
|
||||
module=module,
|
||||
resource_type=resource_type,
|
||||
gen_type="private_portrait",
|
||||
duration_seconds=duration_seconds,
|
||||
max_bytes=max_bytes,
|
||||
)
|
||||
out = PrivatePortraitUploadOut(
|
||||
url=result.url,
|
||||
filename=result.filename,
|
||||
type=result.resource_type,
|
||||
module=result.module,
|
||||
resource_id=result.resource_id,
|
||||
file_size_bytes=result.file_size_bytes,
|
||||
duration_seconds=result.duration_seconds,
|
||||
)
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_UPLOAD_SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=current_user.id,
|
||||
detail={
|
||||
"module": module,
|
||||
"library_type": library_type,
|
||||
"resource_type": resource_type,
|
||||
"resource_id": result.resource_id,
|
||||
"url": result.url,
|
||||
"file_size_bytes": result.file_size_bytes,
|
||||
"duration_seconds": result.duration_seconds,
|
||||
},
|
||||
)
|
||||
return out
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log_operation_error(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_UPLOAD_FAILED.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=current_user.id,
|
||||
exc=exc,
|
||||
detail={
|
||||
"module": module,
|
||||
"library_type": library_type,
|
||||
"resource_type": resource_type,
|
||||
"filename": file.filename,
|
||||
},
|
||||
)
|
||||
raise
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
@@ -29,6 +30,7 @@ from app.schemas.shot_replicate import (
|
||||
ShotSegmentDeleteOut,
|
||||
ShotSegmentDetailOut,
|
||||
ShotSegmentListOut,
|
||||
ShotSegmentSplitRetryOut,
|
||||
ShotReanalyzeOut,
|
||||
ShotSegmentOut,
|
||||
ShotSplitByAIOut,
|
||||
@@ -500,6 +502,97 @@ async def create_segments_by_ai(
|
||||
)
|
||||
|
||||
|
||||
async def prepare_retry_split_segment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
segment_id: str,
|
||||
force: bool = False,
|
||||
reason: str | None = None,
|
||||
) -> ShotSegmentSplitRetryOut:
|
||||
"""重置失败切片片段,commit 成功后由 API 投递现有 split_one_segment 任务。"""
|
||||
segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user, for_update=True)
|
||||
task_set = await get_task_set_for_user(db, task_set_id=segment.task_set_id, user=current_user, for_update=True)
|
||||
from_split_status = segment.split_status
|
||||
allowed = {ShotSplitStatusEnum.FAILED.value, ShotSplitStatusEnum.RETRY_WAITING.value}
|
||||
|
||||
reject_reason: str | None = None
|
||||
if task_set.deleted_at is not None or task_set.status == ShotTaskSetStatusEnum.DELETED.value:
|
||||
reject_reason = "拆镜总任务集已删除,不能重试切片"
|
||||
elif segment.deleted_at is not None:
|
||||
reject_reason = "拆镜片段已删除,不能重试切片"
|
||||
elif from_split_status == ShotSplitStatusEnum.PROCESSING.value:
|
||||
reject_reason = "拆镜片段正在切片处理中,不能重复投递"
|
||||
elif from_split_status == ShotSplitStatusEnum.COMPLETED.value:
|
||||
reject_reason = "拆镜片段已切片完成,不支持重切,避免旧切片资源覆盖"
|
||||
elif from_split_status not in allowed and not force:
|
||||
reject_reason = "仅允许失败或等待重试的切片片段重新投递"
|
||||
elif not task_set.video_path:
|
||||
reject_reason = "原视频本地路径为空,不能重试切片"
|
||||
elif not Path(str(task_set.video_path)).exists():
|
||||
reject_reason = "原视频本地文件不存在,不能重试切片"
|
||||
|
||||
if reject_reason:
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_SPLIT_RETRY_REJECTED.value,
|
||||
project_id=task_set.id,
|
||||
step_id=segment.id,
|
||||
status="rejected",
|
||||
message=reject_reason,
|
||||
detail={
|
||||
"segment_id": segment.id,
|
||||
"task_set_id": task_set.id,
|
||||
"from_split_status": from_split_status,
|
||||
"force": force,
|
||||
"reason": reason,
|
||||
},
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=reject_reason)
|
||||
|
||||
now = _now()
|
||||
segment.split_status = ShotSplitStatusEnum.PENDING.value
|
||||
segment.split_enqueued_at = now
|
||||
segment.split_started_at = None
|
||||
segment.split_lease_until = None
|
||||
segment.split_next_retry_at = None
|
||||
segment.split_retry_count = 0
|
||||
segment.split_last_error = None
|
||||
segment.split_celery_task_id = f"shot-split:{uuid.uuid4().hex}"
|
||||
task_set.split_error_message = None
|
||||
|
||||
await refresh_task_set_split_summary(db, task_set.id)
|
||||
await db.flush()
|
||||
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_SPLIT_RETRY_RECEIVED.value,
|
||||
project_id=task_set.id,
|
||||
step_id=segment.id,
|
||||
status="pending",
|
||||
message="拆镜片段切片失败重试已重置,等待投递 Celery",
|
||||
detail={
|
||||
"segment_id": segment.id,
|
||||
"task_set_id": task_set.id,
|
||||
"from_split_status": from_split_status,
|
||||
"to_split_status": segment.split_status,
|
||||
"force": force,
|
||||
"reason": reason,
|
||||
"source_path": task_set.video_path,
|
||||
"celery_task_name": "shot_replicate.split_one_segment",
|
||||
"queue": "gen_result_download",
|
||||
},
|
||||
)
|
||||
|
||||
return ShotSegmentSplitRetryOut(
|
||||
message="切片重试已提交,正在重新切割视频片段",
|
||||
task_set_id=task_set.id,
|
||||
segment_id=segment.id,
|
||||
split_status=segment.split_status,
|
||||
celery_task_name="shot_replicate.split_one_segment",
|
||||
)
|
||||
|
||||
|
||||
async def create_custom_segment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
|
||||
@@ -34,7 +34,7 @@ class ShotVideoAnalysisResult:
|
||||
|
||||
|
||||
def _timeout_seconds() -> int:
|
||||
return int(getattr(settings, "SHOT_ANALYSIS_TIMEOUT_SECONDS", 180) or 180)
|
||||
return int(getattr(settings, "SHOT_ANALYSIS_TIMEOUT_SECONDS", 3600) or 3600)
|
||||
|
||||
|
||||
def _video_fps() -> float:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -15,9 +15,11 @@ from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceTy
|
||||
COMMON_IMAGE_RE = re.compile(r"^images/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/video_img_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
COMMON_VIDEO_RE = re.compile(r"^videos/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/video_ref_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
COMMON_AUDIO_RE = re.compile(r"^audios/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/audio_ref_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
MODULE_RE = re.compile(r"^(?P<module>hot_opening_replicate|shot_replicate)/(?P<kind>images|videos)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<prefix>video_img|video_ref)_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
MODULE_RE = re.compile(r"^(?P<module>hot_opening_replicate|shot_replicate|private_portrait_real|private_portrait_virtual)/(?P<kind>images|videos)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<prefix>video_img|video_ref)_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
SHOT_SEGMENT_RE = re.compile(r"^shot_segments/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<segment_id>[^/]+)\.mp4$")
|
||||
LEGACY_GEN_RE = re.compile(r"^(?P<kind>images|videos)/gen_(?P<user_id>[^_]+)_(?P<rand>[0-9a-zA-Z]+)\.(?P<ext>[^/]+)$")
|
||||
ADMIN_UPLOAD_RE = re.compile(r"^admin_uploads/(?P<scene>system_logo|system_pdf|open_type_thumb|common)/(?P<kind>images|videos|audios|files)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<prefix>admin_img|admin_video|admin_audio|admin_pdf|admin_file)_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
HOME_MATERIAL_REFERENCE_RE = re.compile(r"^home_materials/references/(?P<kind>images|videos|audios)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<prefix>admin_img|admin_video|admin_audio)_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
|
||||
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".svg"}
|
||||
VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv"}
|
||||
@@ -115,6 +117,26 @@ def parse_upload_path(path: str | os.PathLike[str], *, include_legacy: bool = Fa
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
for pattern, module in (
|
||||
(ADMIN_UPLOAD_RE, UploadResourceModuleEnum.ADMIN_UPLOAD.value),
|
||||
(HOME_MATERIAL_REFERENCE_RE, UploadResourceModuleEnum.HOME_MATERIAL.value),
|
||||
):
|
||||
m = pattern.match(rel)
|
||||
if m:
|
||||
d = m.groupdict()
|
||||
kind = d.get("kind")
|
||||
if kind == "images":
|
||||
rtype = UploadResourceTypeEnum.IMAGE.value
|
||||
elif kind == "videos":
|
||||
rtype = UploadResourceTypeEnum.VIDEO.value
|
||||
elif kind == "audios":
|
||||
rtype = UploadResourceTypeEnum.AUDIO.value
|
||||
elif d.get("prefix") == "admin_pdf":
|
||||
rtype = UploadResourceTypeEnum.PDF.value
|
||||
else:
|
||||
rtype = UploadResourceTypeEnum.FILE.value
|
||||
return _base(abs_path, module, rtype, d.get("user_id"), _parse_created_at(d))
|
||||
|
||||
ignored_prefixes = ("home_materials/",)
|
||||
if rel in {"site_logo.png"} or rel.startswith(ignored_prefixes) or rel.startswith("pdf_"):
|
||||
return ParsedUploadPath(
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 459 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 897 KiB |
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -28,7 +28,11 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<<<<<<< HEAD
|
||||
<script type="module" crossorigin src="/assets/index-Bykj74Cw.js"></script>
|
||||
=======
|
||||
<script type="module" crossorigin src="/assets/index-CM_dxMTI.js"></script>
|
||||
>>>>>>> 8d4ed4a3d1f07530ddbd4e3efdf6a28db8f3b20d
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-JhRVnnL-.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -165,6 +165,43 @@ export async function uploadVideo(file: File, durationSeconds?: number): Promise
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
function privatePortraitUploadEndpoint(path: string, durationSeconds?: number): string {
|
||||
const base = `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api${path}`;
|
||||
const query = typeof durationSeconds === 'number' && durationSeconds > 0
|
||||
? `?duration_seconds=${encodeURIComponent(String(durationSeconds))}`
|
||||
: '';
|
||||
return `${base}${query}`;
|
||||
}
|
||||
|
||||
async function uploadPrivatePortraitFile(path: string, file: File, durationSeconds?: number, errorMessage = '素材上传失败'): Promise<UploadResourceResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch(privatePortraitUploadEndpoint(path, durationSeconds), {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error(errorMessage);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function uploadPrivatePortraitImage(file: File): Promise<UploadResourceResult> {
|
||||
return uploadPrivatePortraitFile('/private-portrait/uploads/image', file, undefined, '真人图片素材上传失败');
|
||||
}
|
||||
|
||||
export async function uploadPrivatePortraitVideo(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
|
||||
return uploadPrivatePortraitFile('/private-portrait/uploads/video', file, durationSeconds, '真人视频素材上传失败');
|
||||
}
|
||||
|
||||
export async function uploadPrivatePortraitVirtualImage(file: File): Promise<UploadResourceResult> {
|
||||
return uploadPrivatePortraitFile('/private-portrait/virtual/uploads/image', file, undefined, '虚拟图片素材上传失败');
|
||||
}
|
||||
|
||||
export async function uploadPrivatePortraitVirtualVideo(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
|
||||
return uploadPrivatePortraitFile('/private-portrait/virtual/uploads/video', file, durationSeconds, '虚拟视频素材上传失败');
|
||||
}
|
||||
|
||||
export async function uploadHotOpeningImage(file: File): Promise<UploadResourceResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
@@ -962,7 +999,7 @@ export async function getPrivatePortraitValidateSession(sessionId: string): Prom
|
||||
return api.get<PrivatePortraitValidateSession>(`/private-portrait/validate-sessions/${sessionId}`);
|
||||
}
|
||||
|
||||
export async function createPrivatePortraitAsset(projectId: string, payload: { url: string; assetType?: string; name?: string | null; videoDuration?: number | null; videoCoverUrl?: string | null; fileSize?: number | null; mimeType?: 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; uploadResourceId?: string | null }): Promise<PrivatePortraitAsset> {
|
||||
return api.post<PrivatePortraitAsset>(`/private-portrait/projects/${projectId}/assets`, {
|
||||
url: payload.url,
|
||||
asset_type: payload.assetType || 'Image',
|
||||
@@ -971,6 +1008,7 @@ export async function createPrivatePortraitAsset(projectId: string, payload: { u
|
||||
video_cover_url: payload.videoCoverUrl || null,
|
||||
file_size: payload.fileSize ?? null,
|
||||
mime_type: payload.mimeType || null,
|
||||
upload_resource_id: payload.uploadResourceId || null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1032,7 +1070,7 @@ export async function deletePrivatePortraitVirtualProject(projectId: string): Pr
|
||||
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> {
|
||||
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; uploadResourceId?: string | null }): Promise<PrivatePortraitAsset> {
|
||||
return api.post<PrivatePortraitAsset>(`/private-portrait/virtual-projects/${projectId}/assets`, {
|
||||
url: payload.url,
|
||||
asset_type: payload.assetType || 'Image',
|
||||
@@ -1041,6 +1079,7 @@ export async function createPrivatePortraitVirtualAsset(projectId: string, paylo
|
||||
video_cover_url: payload.videoCoverUrl || null,
|
||||
file_size: payload.fileSize ?? null,
|
||||
mime_type: payload.mimeType || null,
|
||||
upload_resource_id: payload.uploadResourceId || null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { Button, Input, Modal, Space, Upload, message } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
import { createPrivatePortraitAsset, uploadImage, uploadVideo } from '../../../api';
|
||||
import { createPrivatePortraitAsset, uploadPrivatePortraitImage, uploadPrivatePortraitVideo } from '../../../api';
|
||||
|
||||
const MIN_PRIVATE_VIDEO_DURATION = 2;
|
||||
const MAX_PRIVATE_VIDEO_DURATION = 15;
|
||||
@@ -89,14 +89,15 @@ const PrivatePortraitAssetUpload: React.FC<Props> = ({ projectId, open, onClose,
|
||||
return;
|
||||
}
|
||||
|
||||
const uploaded = assetType === 'Video' ? await uploadVideo(file) : await uploadImage(file);
|
||||
const uploaded = assetType === 'Video' ? await uploadPrivatePortraitVideo(file, videoDuration || undefined) : await uploadPrivatePortraitImage(file);
|
||||
await createPrivatePortraitAsset(projectId, {
|
||||
url: uploaded.url,
|
||||
assetType,
|
||||
name: name.trim() || file.name,
|
||||
videoDuration,
|
||||
fileSize: file.size,
|
||||
videoDuration: uploaded.duration_seconds ?? videoDuration,
|
||||
fileSize: uploaded.file_size_bytes ?? file.size,
|
||||
mimeType: file.type || null,
|
||||
uploadResourceId: uploaded.resource_id || null,
|
||||
});
|
||||
message.success(assetType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中');
|
||||
reset();
|
||||
|
||||
@@ -63,12 +63,12 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
|
||||
const items = useMemo(() => [
|
||||
{
|
||||
key: 'real_person',
|
||||
label: '真人素材',
|
||||
label: '真人素材库',
|
||||
children: <RealPersonLibraryPanel />,
|
||||
},
|
||||
{
|
||||
key: 'aigc_virtual',
|
||||
label: '虚拟素材',
|
||||
label: '虚拟素材库',
|
||||
children: <VirtualMaterialPanel />,
|
||||
},
|
||||
], []);
|
||||
@@ -84,12 +84,12 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div>
|
||||
{/* <div style={{ marginBottom: 16 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>私域素材库</Title>
|
||||
<Text type="secondary">统一管理真人素材和虚拟素材。真人项目组需先完成人脸认证,虚拟项目组会同步创建火山 AIGC Asset Group。</Text>
|
||||
</div>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Text type="secondary">统一管理真人素材和虚拟素材。真人素材需先完成人脸认证后才可上传素材,虚拟素材如需使用需先上传。</Text>
|
||||
</div> */}
|
||||
{/* <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>
|
||||
@@ -103,7 +103,7 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
|
||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{projects.length}</div>
|
||||
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>
|
||||
{activeKey === 'aigc_virtual'
|
||||
? '虚拟人像项目会同步创建火山 AIGC Asset Group。'
|
||||
? '虚拟素材如需使用需先上传'
|
||||
: '真人项目组需完成人脸认证后才可上传素材。'}
|
||||
</Paragraph>
|
||||
</Card>
|
||||
@@ -112,15 +112,18 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
|
||||
<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>
|
||||
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>仅 入库成功 状态素材可在 AI 创作中引用。</Paragraph>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Row> */}
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={handleTabChange}
|
||||
items={items}
|
||||
destroyInactiveTabPane={false}
|
||||
tabBarExtraContent={
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}。真人/虚拟共用,图片/视频共用;音频暂不开放。</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -117,32 +117,29 @@ const RealPersonLibraryPanel: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>真人素材库</Typography.Title>
|
||||
<Typography.Text type="secondary">创建项目组时先完成真人认证,认证成功后项目组才会正式创建并可上传素材。</Typography.Text>
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>真人素材库</Typography.Title>
|
||||
<Typography.Text type="secondary">创建项目组时先完成真人认证,认证成功后项目组才会正式创建并可上传素材。</Typography.Text>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>创建项目组</Button>
|
||||
</div>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={7}>
|
||||
<Col xs={24} lg={5}>
|
||||
<Card
|
||||
title={<span>真人素材项目组</span>}
|
||||
extra={(
|
||||
<Space size={8}>
|
||||
{/* <Button icon={<ReloadOutlined />} onClick={loadProjects} loading={loading} size="small">刷新</Button> */}
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)} size="small">新建项目组</Button>
|
||||
</Space>
|
||||
)}
|
||||
// title="真人素材项目组"
|
||||
style={{ borderRadius: 16, minHeight: 520 }}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{projects.length === 0 ? (
|
||||
<Empty description="暂无项目组" />
|
||||
<Empty description="暂无真人项目组" />
|
||||
) : (
|
||||
<PrivatePortraitProjectList items={projects} selectedId={selected?.id} onSelect={setSelected} />
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={17}>
|
||||
<Col xs={24} lg={19}>
|
||||
{selected ? (
|
||||
<PrivatePortraitProjectDetail project={selected} onDeleted={() => { setSelected(null); loadProjects(); }} onChanged={loadProjects} />
|
||||
) : (
|
||||
|
||||
@@ -35,8 +35,8 @@ import {
|
||||
deletePrivatePortraitVirtualProject,
|
||||
getPrivatePortraitVirtualAssets,
|
||||
getPrivatePortraitVirtualProjects,
|
||||
uploadImage,
|
||||
uploadVideo,
|
||||
uploadPrivatePortraitVirtualImage,
|
||||
uploadPrivatePortraitVirtualVideo,
|
||||
} from '../../../api';
|
||||
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types';
|
||||
|
||||
@@ -276,14 +276,15 @@ const VirtualMaterialPanel: React.FC = () => {
|
||||
if (currentType === 'Video' && !validatePrivateVideoDuration(duration, message.error)) {
|
||||
return;
|
||||
}
|
||||
const uploaded = currentType === 'Video' ? await uploadVideo(file) : await uploadImage(file);
|
||||
const uploaded = currentType === 'Video' ? await uploadPrivatePortraitVirtualVideo(file, duration || undefined) : await uploadPrivatePortraitVirtualImage(file);
|
||||
await createPrivatePortraitVirtualAsset(selectedProjectId, {
|
||||
url: uploaded.url,
|
||||
assetType: currentType,
|
||||
name: assetName.trim() || file.name,
|
||||
videoDuration: duration,
|
||||
fileSize: file.size,
|
||||
videoDuration: uploaded.duration_seconds ?? duration,
|
||||
fileSize: uploaded.file_size_bytes ?? file.size,
|
||||
mimeType: file.type || null,
|
||||
uploadResourceId: uploaded.resource_id || null,
|
||||
});
|
||||
message.success(currentType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中');
|
||||
setUploadOpen(false);
|
||||
@@ -385,13 +386,19 @@ const VirtualMaterialPanel: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={7}>
|
||||
<div >
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>虚拟素材库</Typography.Title>
|
||||
<Typography.Text type="secondary">可提前上传虚拟人像素材,后续生成视频时可直接使用。</Typography.Text>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>创建项目组</Button>
|
||||
</div>
|
||||
<Row gutter={[10, 10]}>
|
||||
<Col xs={24} lg={5} >
|
||||
<Card
|
||||
title="虚拟人像项目组"
|
||||
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>创建项目组</Button>}
|
||||
style={{ borderRadius: 16, minHeight: 520 }}
|
||||
// title="虚拟人像项目组"
|
||||
style={{ borderRadius: 16, minHeight: 520, margin: '0 auto', maxWidth: 1200}}
|
||||
>
|
||||
<Spin spinning={projectLoading}>
|
||||
{projects.length === 0 ? (
|
||||
@@ -434,7 +441,7 @@ const VirtualMaterialPanel: React.FC = () => {
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={17}>
|
||||
<Col xs={24} lg={19}>
|
||||
<Card
|
||||
title={selectedProject ? selectedProject.name : '素材资产'}
|
||||
extra={(
|
||||
@@ -518,7 +525,7 @@ const VirtualMaterialPanel: React.FC = () => {
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={handleCreateProject}
|
||||
confirmLoading={creatingProject}
|
||||
okText="创建并同步火山 Asset Group"
|
||||
okText="创建项目组"
|
||||
>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="name" label="项目组名称" rules={[{ required: true, message: '请输入项目组名称' }]}>
|
||||
|
||||
@@ -384,30 +384,38 @@ const AIChatPage: React.FC = () => {
|
||||
config = {
|
||||
perSecondCredits: 2,
|
||||
baseCredits: 60,
|
||||
ratio: 1,
|
||||
inputVideoRatio: 1,
|
||||
ratio: 1.3,
|
||||
inputVideoRatio: 1.3,
|
||||
inputVideoBaseCredits: 0,
|
||||
inputVideoPerSecondCredits: 0.5,
|
||||
inputVideoPerSecondCredits: 15,
|
||||
inputImageRatio: 1,
|
||||
inputImageBaseCredits: 0,
|
||||
inputImagePerImageCredits: 0.0,
|
||||
};
|
||||
if (videoResolution === '1080p') {
|
||||
config.ratio = 2;
|
||||
config.ratio = 1.3;
|
||||
} else if (videoResolution === '720p') {
|
||||
config.ratio = 1.5;
|
||||
config.ratio = 1.3;
|
||||
} else if (videoResolution === '480p') {
|
||||
config.ratio = 1;
|
||||
config.ratio = 1.3;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// 如果没有找到对应的配置,则使用默认配置
|
||||
config = {
|
||||
perSecondCredits: 0.1,
|
||||
baseCredits: 4,
|
||||
ratio: 1,
|
||||
baseCredits: 2,
|
||||
ratio: 3,
|
||||
inputImageRatio: 1,
|
||||
inputImageBaseCredits: 0,
|
||||
inputImagePerImageCredits: 0.0,
|
||||
};
|
||||
if (selectedResolution === '2K') {
|
||||
config.ratio = 1;
|
||||
config.ratio = 3;
|
||||
} else if (selectedResolution === '4K') {
|
||||
config.ratio = 2;
|
||||
config.ratio = 3;
|
||||
} else if (selectedResolution === '1K') {
|
||||
config.ratio = 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,10 +432,27 @@ const AIChatPage: React.FC = () => {
|
||||
const inputVideoCost = ((config.inputVideoBaseCredits || 0) + (config.inputVideoPerSecondCredits || 0) * inputVideoDuration) * (config.inputVideoRatio || 1);
|
||||
total += inputVideoCost;
|
||||
}
|
||||
// 传入图片积分
|
||||
const inputImageCount = currentMedia
|
||||
.filter((m) => m.type === 'image')
|
||||
.length;
|
||||
if (inputImageCount > 0) {
|
||||
const inputImageCost = ((config.inputImageBaseCredits || 0) + (config.inputImagePerImageCredits || 0) * inputImageCount) * (config.inputImageRatio || 1);
|
||||
total += inputImageCost;
|
||||
}
|
||||
return Number(total.toFixed(2));
|
||||
} else {
|
||||
// 图片:baseCredits × ratio
|
||||
return Number((config.baseCredits * config.ratio).toFixed(2));
|
||||
let total = config.baseCredits * config.ratio;
|
||||
// 传入图片积分
|
||||
const inputImageCount = currentMedia
|
||||
.filter((m) => m.type === 'image')
|
||||
.length;
|
||||
if (inputImageCount > 0) {
|
||||
const inputImageCost = ((config.inputImageBaseCredits || 0) + (config.inputImagePerImageCredits || 0) * inputImageCount) * (config.inputImageRatio || 1);
|
||||
total += inputImageCost;
|
||||
}
|
||||
return Number(total.toFixed(2));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1849,9 +1874,9 @@ const AIChatPage: React.FC = () => {
|
||||
// ==================== 渲染 ====================
|
||||
|
||||
const isFirstLastFrameComposer = mediaType === 'video' && referenceMode === 'first_last_frame';
|
||||
const composerCanSend = isFirstLastFrameComposer
|
||||
const composerCanSend = !uploading && (isFirstLastFrameComposer
|
||||
? Boolean(inputValue.trim() || firstFrame)
|
||||
: Boolean(inputValue.trim() || currentMedia.length > 0);
|
||||
: Boolean(inputValue.trim() || currentMedia.length > 0));
|
||||
const composerModeLabel = mediaType === 'image'
|
||||
? '图片生成'
|
||||
: isFirstLastFrameComposer
|
||||
|
||||
@@ -68,6 +68,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
const [previewItem, setPreviewItem] = useState<any>(null);
|
||||
const videoRef = React.createRef<HTMLVideoElement>();
|
||||
const [selectedDate, setSelectedDate] = useState<string>('');
|
||||
const [searchKeyword, setSearchKeyword] = useState<string>('');
|
||||
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
@@ -175,11 +176,23 @@ const GeneratedRecord: React.FC = () => {
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
// 预览文件
|
||||
const handlePreview = (item: any) => {
|
||||
const handlePreview = async (item: any) => {
|
||||
setPreviewItem(item);
|
||||
setPreviewVisible(true);
|
||||
// 触发事件通知布局组件关闭浮动按钮
|
||||
window.dispatchEvent(new Event('previewOpen'));
|
||||
|
||||
// 如果是视频且没有尺寸信息,异步获取视频尺寸
|
||||
if (item.videoUrl && item.videoUrl.trim() && !item.imagePx) {
|
||||
try {
|
||||
const dimensions = await getVideoDimensions(item.videoUrl);
|
||||
if (dimensions) {
|
||||
setPreviewItem((prev: any) => prev ? { ...prev, imagePx: dimensions } : prev);
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略获取尺寸失败
|
||||
}
|
||||
}
|
||||
};
|
||||
// 关闭预览并暂停视频
|
||||
const handleClosePreview = () => {
|
||||
@@ -778,11 +791,17 @@ const GeneratedRecord: React.FC = () => {
|
||||
if (historySource) {
|
||||
parameters = `?gen_type=${filterMedia}&history_source=${historySource}&page=${Pagebreak.page}&page_size=${Pagebreak.pageSize}`;
|
||||
}
|
||||
if (searchKeyword && searchKeyword.trim()) {
|
||||
parameters += `&keyword=${encodeURIComponent(searchKeyword.trim())}`;
|
||||
}
|
||||
if (selectedDate) {
|
||||
let parameters = `${selectedDate}?gen_type=${filterMedia}&page=${Pagebreak.page}&page_size=${Pagebreak.pageSize}`;
|
||||
if (historySource) {
|
||||
parameters = `${selectedDate}?gen_type=${filterMedia}&history_source=${historySource}&page=${Pagebreak.page}&page_size=${Pagebreak.pageSize}`;
|
||||
}
|
||||
if (searchKeyword && searchKeyword.trim()) {
|
||||
parameters += `&keyword=${encodeURIComponent(searchKeyword.trim())}`;
|
||||
}
|
||||
gethistoryItems(parameters).then((res: any) => {
|
||||
const data = Array.isArray(res) ? res : (res?.items || []);
|
||||
let recordList = [{
|
||||
@@ -796,21 +815,6 @@ const GeneratedRecord: React.FC = () => {
|
||||
} else {
|
||||
setRecordList([]);
|
||||
}
|
||||
if (isPageLoaded) {
|
||||
data.forEach(async (item: any) => {
|
||||
if (item.videoUrl && item.videoUrl.trim()) {
|
||||
const dimensions = await getVideoDimensions(item.videoUrl);
|
||||
if (dimensions) {
|
||||
setRecordList(prev => prev.map(group => ({
|
||||
...group,
|
||||
items: group.items.map((i: any) =>
|
||||
i.id === item.id ? { ...i, imagePx: dimensions } : i
|
||||
),
|
||||
})));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catch((err) => {
|
||||
}).finally(() => {
|
||||
setLoading(false);
|
||||
@@ -831,23 +835,6 @@ const GeneratedRecord: React.FC = () => {
|
||||
setRecordList(prev => [...prev, ...data]);
|
||||
}
|
||||
setTotalnumber(res?.totalDays || 0);
|
||||
if (isPageLoaded) {
|
||||
data.forEach(group => {
|
||||
group.items.forEach(async (item: any) => {
|
||||
if (item.videoUrl && item.videoUrl.trim()) {
|
||||
const dimensions = await getVideoDimensions(item.videoUrl);
|
||||
if (dimensions) {
|
||||
setRecordList(prev => prev.map(g => ({
|
||||
...g,
|
||||
items: g.items.map((i: any) =>
|
||||
i.id === item.id ? { ...i, imagePx: dimensions } : i
|
||||
),
|
||||
})));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}).catch((err) => {
|
||||
if (Pagebreak.page === 1) {
|
||||
setRecordList([]);
|
||||
@@ -885,6 +872,9 @@ const GeneratedRecord: React.FC = () => {
|
||||
if (historySource) {
|
||||
parameters = `${time}?gen_type=${filterMedia}&history_source=${historySource}&page=${addpage}&page_size=${Pagebreak.pageSize}`;
|
||||
}
|
||||
if (searchKeyword && searchKeyword.trim()) {
|
||||
parameters += `&keyword=${encodeURIComponent(searchKeyword.trim())}`;
|
||||
}
|
||||
try {
|
||||
const res: any = await gethistoryItems(parameters);
|
||||
const newItems: any[] = res.items || [];
|
||||
@@ -909,12 +899,13 @@ const GeneratedRecord: React.FC = () => {
|
||||
});
|
||||
}
|
||||
};
|
||||
// 当筛选条件改变时,重置页码
|
||||
// 当筛选条件改变时,重置页码和搜索关键词
|
||||
useEffect(() => {
|
||||
setPagebreak(prev => ({
|
||||
...prev,
|
||||
page: 1
|
||||
}));
|
||||
setSearchKeyword('');
|
||||
}, [filterType, filterMedia]);
|
||||
return (
|
||||
<div className="content_box" >
|
||||
@@ -1218,6 +1209,24 @@ const GeneratedRecord: React.FC = () => {
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
<Input.Search
|
||||
placeholder="搜索提示词"
|
||||
allowClear
|
||||
value={searchKeyword}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setSearchKeyword(val);
|
||||
if (!val) {
|
||||
setPagebreak(prev => ({ ...prev, page: 1 }));
|
||||
loadRecordList();
|
||||
}
|
||||
}}
|
||||
onSearch={() => {
|
||||
setPagebreak(prev => ({ ...prev, page: 1 }));
|
||||
loadRecordList();
|
||||
}}
|
||||
style={{ width: 240, borderRadius: 8 }}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
<Button
|
||||
@@ -1325,6 +1334,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
<img
|
||||
src={displayUrl}
|
||||
alt="预览"
|
||||
loading="lazy"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
||||