This commit is contained in:
sjy
2026-07-09 18:31:01 +08:00
71 changed files with 3736 additions and 504 deletions
File diff suppressed because one or more lines are too long
+36 -36
View File
@@ -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>
+34 -40
View File
@@ -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();
+90 -18
View File
@@ -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>
+39 -39
View File
@@ -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: '用户手机号',
+5 -1
View File
@@ -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;
+57
View File
@@ -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
View File
@@ -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 ###
+2
View File
@@ -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)
+6 -2
View File
@@ -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),
+41
View File
@@ -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_logoPDF使用 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
+3
View File
@@ -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"}
+12
View File
@@ -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,
)
+94 -1
View File
@@ -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="上传真人图片素材并写入 UploadResourcemodule=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="上传真人视频素材并写入 UploadResourcemodule=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="上传虚拟图片素材并写入 UploadResourcemodule=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="上传虚拟视频素材并写入 UploadResourcemodule=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,
+1 -1
View File
@@ -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
+55
View File
@@ -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: "后台公共上传",
}
+16
View File
@@ -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: "文件",
}
+1 -1
View File
@@ -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
+3
View File
@@ -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,
+30
View File
@@ -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 的附件引用对象。")
+18
View File
@@ -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):
+47 -3
View File
@@ -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="提示消息。")
+18 -3
View File
@@ -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 / VideoAudio 会被拒绝。",
@@ -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")
+3 -1
View File
@@ -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
+23 -2
View File
@@ -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(
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

-14
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 897 KiB

-24
View File
@@ -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

+4
View File
@@ -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>
+41 -2
View File
@@ -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: '请输入项目组名称' }]}>
+38 -13
View File
@@ -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
+44 -34
View File
@@ -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%',