Merge branch 'main' of gitee.com:wg123/video-gen

This commit is contained in:
Lrd
2026-07-09 13:48:30 +08:00
71 changed files with 4758 additions and 1655 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-kx3oQI_t.js"></script>
<script type="module" crossorigin src="/assets/index-Bq0Cmo9c.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head>
<body>
+23 -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') {
+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>
+41 -19
View File
@@ -41,25 +41,35 @@ function parseSizes(val: unknown): Record<string, Record<string, string>> {
// Default size options with pixel mappings
const SIZE_OPTIONS: Record<string, Record<string, string>> = {
"1K": {
"1:1": "1024x1024",
"4:3": "1152x864",
"3:4": "864x1152",
"16:9": "1312x736",
"9:16": "736x1312",
"3:2": "1248x832",
"2:3": "832x1248",
"21:9": "1568x672",
},
"2K": {
"1:1": "2048×2048",
"4:3": "2304×1728",
"3:4": "1728×2304",
"16:9": "2560×1440",
"9:16": "1600×2848",
"3:2": "2496×1664",
"2:3": "1664×2496",
"21:9": "3024×1296",
"1:1": "2048x2048",
"4:3": "2304x1728",
"3:4": "1728x2304",
"16:9": "2848x1600",
"9:16": "1600x2848",
"3:2": "2496x1664",
"2:3": "1664x2496",
"21:9": "3136x1344",
},
"4K": {
"1:1": "4096×4096",
"4:3": "4608×3456",
"3:4": "3520×4704",
"16:9": "5404×3040",
"9:16": "3040×5504",
"3:2": "4992×3328",
"2:3": "3328×4992",
"21:9": "6197×2656",
"1:1": "4096x4096",
"4:3": "4704x3520",
"3:4": "3520x4704",
"16:9": "5504x3040",
"9:16": "3040x5504",
"3:2": "4992x3328",
"2:3": "3328x4992",
"21:9": "6197x2656",
},
};
@@ -94,7 +104,7 @@ const AdminImageEngines: React.FC = () => {
const values = await form.validateFields();
// Build supportedSizes from form values
const sizes: Record<string, Record<string, string>> = {};
for (const tier of ["2K", "4K"]) {
for (const tier of ["1K", "2K", "4K"]) {
const selected: string[] = values[`size_${tier}`] || [];
if (selected.length > 0) {
sizes[tier] = {};
@@ -147,7 +157,7 @@ const AdminImageEngines: React.FC = () => {
setModal({ open: true, engine: engine || null });
if (engine) {
const sizeFields: Record<string, string[]> = {};
for (const tier of ["2K", "4K"]) {
for (const tier of ["1K", "2K", "4K"]) {
sizeFields[`size_${tier}`] = Object.keys(engine.supportedSizes?.[tier] || {});
}
form.setFieldsValue({
@@ -161,6 +171,7 @@ const AdminImageEngines: React.FC = () => {
supportedModels: ['doubao-seedream-5-0-260128'],
defaultSize: '2K',
maxImageCount: 0,
size_1K: ALL_RATIOS,
size_2K: ALL_RATIOS,
size_4K: ALL_RATIOS,
});
@@ -187,6 +198,16 @@ const AdminImageEngines: React.FC = () => {
</div>
),
},
{
title: '1K 支持比例', key: 'sizes_1k', width: 260,
render: (_: any, r: ImageEngine) => {
const ratios = Object.keys(r.supportedSizes?.["1K"] || {});
if (ratios.length === 0) return <span style={{ color: '#bfbfbf' }}>-</span>;
return <Space size={2} wrap>{ratios.map(ratio => (
<Tag key={ratio} color="green">{ratio} {r.supportedSizes["1K"][ratio]}</Tag>
))}</Space>;
},
},
{
title: '2K 支持比例', key: 'sizes_2k', width: 260,
render: (_: any, r: ImageEngine) => {
@@ -294,7 +315,7 @@ const AdminImageEngines: React.FC = () => {
</Typography.Text>
</div>
{["2K", "4K"].map(tier => (
{["1K", "2K", "4K"].map(tier => (
<div key={tier} style={{
background: '#fafbfc', borderRadius: 10, padding: '12px 16px',
marginBottom: 12, border: '1px solid #f0f0f5',
@@ -318,6 +339,7 @@ const AdminImageEngines: React.FC = () => {
<Form.Item name="defaultSize" label="默认尺寸档位">
<Select size="large" options={[
{ value: '1K', label: '1K' },
{ value: '2K', label: '2K' },
{ value: '4K', label: '4K' },
]} />
+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 {
@@ -115,7 +115,7 @@ const AdminVideoEngines: React.FC = () => {
form.resetFields();
form.setFieldsValue({
isActive: true, priority: 0,
maxDuration: 15,
maxDuration: 30,
maxImageCount: 2,
maxVideoCount: 0,
maxAudioCount: 0,
@@ -123,7 +123,7 @@ const AdminVideoEngines: React.FC = () => {
supportsUniversalReference: true,
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
supportedResolutions: ['480p', '720p', '1080p'],
supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30],
});
}
};
@@ -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,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"}
+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,
+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(
@@ -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,
)
@@ -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,
*,
@@ -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(
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-CYqrWKKz.js"></script>
<script type="module" crossorigin src="/assets/index-DOtHKxdv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-JhRVnnL-.css">
</head>
<body>
+62 -3
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);
@@ -633,6 +670,26 @@ export async function removethree(projectId: string, stepId: string ,params: any
export async function removefour(projectId: string, stepId: string ,params: any): Promise<any> {
return api.post(`/shot-replications/projects/${projectId}/steps/${stepId}/generate-video`, params);
}
// 重新分析
export async function reanalyzeShotReplication(taskSetId: string): Promise<any> {
return api.post(`/shot-replications/task-sets/${taskSetId}/reanalyze`);
}
// 删除片段
export async function deleteSegment(segmentId: string): Promise<void> {
await api.delete(`/shot-replications/segments/${segmentId}`);
}
// 重新分析片段
export async function reanalyzeSegment(segmentId: string): Promise<any> {
return api.post(`/shot-replications/segments/${segmentId}/reanalyze`);
}
// 删除拆镜项目
export async function deleteShotReplicationProject(taskSetId: string): Promise<void> {
await api.delete(`/shot-replications/task-sets/${taskSetId}`);
}
// 删除爆款开头复刻任务
export async function deleteHotOpeningReplicationTask(taskId: string): Promise<void> {
await api.delete(`/hot-opening-replications/tasks/${taskId}`);
}
// 获取地区信息
export interface GetAreaParams {
level?: string;
@@ -797,7 +854,7 @@ export async function getHomeCaseHeader(): Promise<any> {
return api.get(`/home-materials/categories`);
}
// 首页素材按钮资源
export async function getHomeCaseButton(id: string,limit:number=5): Promise<any> {
export async function getHomeCaseButton(id: string,limit:number=10): Promise<any> {
return api.get(`/home-materials?category_id=${id}&limit_per_category=${limit}&include_empty_categories=false&response_mode=grouped&page=1&page_size=20`);
}
export async function deleteResourcesMaterial(params:any): Promise<any> {
@@ -937,7 +994,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',
@@ -946,6 +1003,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,
});
}
@@ -1007,7 +1065,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',
@@ -1016,6 +1074,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,
});
}
@@ -179,16 +179,16 @@ const StorageCard: React.FC<{ data: ResourceCapacityData | null }> = ({ data })
justifyContent: 'space-between',
marginBottom: 6,
fontSize: 12,
color: isOver ? '#ffffffff' : '#000000ff',
color: isOver ? '#000000ff' : '#000000ff',
padding: '0 8px',
}}>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<DatabaseOutlined style={{ fontSize: 12, color: isOver ? '#ffffffff' :'#000000ff' }} />
<DatabaseOutlined style={{ fontSize: 12, color: isOver ? '#000000ff' :'#000000ff' }} />
{rawPercent.toFixed(1)}%
</span>
{data.enabled ? (
<span style={{ fontWeight: 500, color: isOver ? '#ffffffff' : '#000000ff' }}>
<span style={{ fontWeight: 500, color: isOver ? '#000000ff' : '#000000ff' }}>
{used.toFixed(2)} / {total.toFixed(2)} {unit}
</span>
) : (
+157 -121
View File
@@ -1,61 +1,79 @@
import React, { useRef, useState } from 'react';
import { Modal, Tooltip } from 'antd';
import { HistoryOutlined, UserOutlined, FolderOpenOutlined, PlusOutlined, TeamOutlined } from '@ant-design/icons';
import { Popover, Tooltip } from 'antd';
import { FolderOpenOutlined, DatabaseOutlined, UserOutlined, TeamOutlined } from '@ant-design/icons';
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset, UploadResourceHistoryItem } from '../types';
import PrivatePortraitAssetPicker from './privatePortrait/picker/AssetPicker';
import UploadResourceHistoryPicker from './uploadResource/UploadResourceHistoryPicker';
interface UploadSelectorProps {
children: React.ReactNode;
accept?: string;
multiple?: boolean;
onLocalSelect?: (files: File[]) => void;
onHistorySelect?: (items: UploadResourceHistoryItem[]) => void;
/** 兼容旧页面:由 UploadSelector 内部打开素材选择器,确认后回传素材数组。 */
onPortraitSelect?: (items: PrivatePortraitSelectableAsset[]) => void;
/** 新页面推荐:只选择素材库类型,父组件自行打开统一选择器。 */
onPortraitLibrarySelect?: (libraryType: PrivatePortraitLibraryType) => void;
uploading?: boolean;
tooltipTitle?: string;
maxImageCount?: number;
maxVideoCount?: number;
usedImageCount?: number;
usedVideoCount?: number;
usedVideoDuration?: number;
maxVideoDuration?: number;
}
const UploadSelector: React.FC<UploadSelectorProps> = ({
children,
accept = 'image/*,video/*',
multiple = true,
onLocalSelect,
onHistorySelect,
onPortraitSelect,
onPortraitLibrarySelect,
uploading,
tooltipTitle,
maxImageCount,
maxVideoCount,
usedImageCount,
usedVideoCount,
usedVideoDuration,
maxVideoDuration,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [modalVisible, setModalVisible] = useState(false);
const [historyModalVisible, setHistoryModalVisible] = useState(false);
const [portraitPickerOpen, setPortraitPickerOpen] = useState(false);
const [portraitLibraryType, setPortraitLibraryType] = useState<PrivatePortraitLibraryType>('real_person');
const [historyModalVisible, setHistoryModalVisible] = useState(false);
const [popoverOpen, setPopoverOpen] = useState(false);
const handleLocalSelect = () => {
setPopoverOpen(false);
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && onLocalSelect) {
onLocalSelect(Array.from(files));
const fileList = Array.from(files);
onLocalSelect(fileList);
}
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleClick = () => {
if (!uploading) {
setModalVisible(true);
}
};
const openPortraitPicker = (libraryType: PrivatePortraitLibraryType) => {
setModalVisible(false);
setPopoverOpen(false);
if (onPortraitSelect) {
setPortraitLibraryType(libraryType);
setPortraitPickerOpen(true);
return;
}
if (onPortraitLibrarySelect) {
onPortraitLibrarySelect(libraryType);
return;
@@ -64,42 +82,101 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
setPortraitPickerOpen(true);
};
const options = [
{
key: 'history',
label: '历史记录',
icon: <HistoryOutlined style={{ fontSize: 20, color: '#6366f1' }} />,
description: '从历史上传记录中选择',
onClick: () => {
setModalVisible(false);
setHistoryModalVisible(true);
},
},
{
key: 'real_person',
label: '真人素材',
icon: <UserOutlined style={{ fontSize: 20, color: '#ec4899' }} />,
description: '从真人私域素材库中选择',
onClick: () => openPortraitPicker('real_person'),
},
{
key: 'aigc_virtual',
label: '虚拟素材',
icon: <TeamOutlined style={{ fontSize: 20, color: '#8b5cf6' }} />,
description: '从虚拟私域素材库中选择',
onClick: () => openPortraitPicker('aigc_virtual'),
},
{
key: 'local',
label: '本地选取',
icon: <FolderOpenOutlined style={{ fontSize: 20, color: '#10b981' }} />,
description: '从本地电脑选择文件',
onClick: () => {
setModalVisible(false);
handleLocalSelect();
},
},
];
const handleHistorySelect = () => {
setPopoverOpen(false);
setHistoryModalVisible(true);
};
const content = (
<div style={{ padding: '4px 0', minWidth: 180 }}>
<div
onClick={() => {
handleLocalSelect();
}}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 16px',
cursor: 'pointer',
transition: 'background 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f1f5f9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<FolderOpenOutlined style={{ fontSize: 14, color: '#64748b' }} />
<span style={{ fontSize: 14, color: '#334155' }}>
{/* {multiple ? '本地上传(可多选拖拽)' : '本地上传'} */}
</span>
</div>
<div
onClick={handleHistorySelect}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 16px',
cursor: 'pointer',
transition: 'background 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f1f5f9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<DatabaseOutlined style={{ fontSize: 14, color: '#64748b' }} />
<span style={{ fontSize: 14, color: '#334155' }}></span>
</div>
<div
onClick={() => openPortraitPicker('real_person')}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 16px',
cursor: 'pointer',
transition: 'background 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f1f5f9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<UserOutlined style={{ fontSize: 14, color: '#64748b' }} />
<span style={{ fontSize: 14, color: '#334155' }}></span>
</div>
<div
onClick={() => openPortraitPicker('aigc_virtual')}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 16px',
cursor: 'pointer',
transition: 'background 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f1f5f9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<TeamOutlined style={{ fontSize: 14, color: '#64748b' }} />
<span style={{ fontSize: 14, color: '#334155' }}></span>
</div>
</div>
);
return (
<>
@@ -107,93 +184,44 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
ref={fileInputRef}
type="file"
accept={accept}
multiple
multiple={multiple}
onChange={handleFileChange}
style={{ display: 'none' }}
/>
{tooltipTitle ? (
<Tooltip title={tooltipTitle}>
<div onClick={handleClick} style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
{children}
</div>
<Popover
content={content}
open={popoverOpen}
onOpenChange={setPopoverOpen}
trigger="click"
placement="bottomLeft"
>
<div style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
{children}
</div>
</Popover>
</Tooltip>
) : (
<div onClick={handleClick} style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
{children}
</div>
<Popover
content={content}
open={popoverOpen}
onOpenChange={setPopoverOpen}
trigger="click"
placement="bottomLeft"
>
<div style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
{children}
</div>
</Popover>
)}
<Modal
title="选择上传来源"
open={modalVisible}
onCancel={() => setModalVisible(false)}
footer={null}
width={400}
centered
destroyOnHidden
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, paddingTop: 8 }}>
{options.map((option) => (
<div
key={option.key}
onClick={option.onClick}
style={{
display: 'flex',
alignItems: 'center',
gap: 16,
padding: '16px 20px',
borderRadius: 12,
background: '#f8fafc',
cursor: 'pointer',
transition: 'all 0.2s ease',
border: '1px solid transparent',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#fff';
e.currentTarget.style.borderColor = '#e2e8f0';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.04)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#f8fafc';
e.currentTarget.style.borderColor = 'transparent';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div
style={{
width: 48,
height: 48,
borderRadius: 12,
background: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
}}
>
{option.icon}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 15, fontWeight: 600, color: '#1e293b', marginBottom: 2 }}>
{option.label}
</div>
<div style={{ fontSize: 13, color: '#64748b' }}>
{option.description}
</div>
</div>
<PlusOutlined style={{ fontSize: 14, color: '#94a3b8' }} />
</div>
))}
</div>
</Modal>
<UploadResourceHistoryPicker
open={historyModalVisible}
onClose={() => setHistoryModalVisible(false)}
onSelect={(items) => {
onHistorySelect?.(items);
setHistoryModalVisible(false);
setModalVisible(false);
}}
allowedTypes={accept.includes('audio') ? ['image', 'video', 'audio'] : accept.includes('video') ? ['image', 'video'] : ['image']}
/>
@@ -206,6 +234,14 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
onPortraitSelect?.(assets);
setPortraitPickerOpen(false);
}}
accept={accept}
maxCount={accept === 'image/*' && !multiple ? 1 : undefined}
maxImageCount={maxImageCount}
maxVideoCount={maxVideoCount}
usedImageCount={usedImageCount}
usedVideoCount={usedVideoCount}
usedVideoDuration={usedVideoDuration}
maxVideoDuration={maxVideoDuration}
/>
</>
);
@@ -1,6 +1,6 @@
import React from 'react';
import { Button, Empty, Popconfirm, Space, Tag, Tooltip } from 'antd';
import { DeleteOutlined, PictureOutlined, ReloadOutlined, VideoCameraOutlined } from '@ant-design/icons';
import React, { useEffect, useRef, useState } from 'react';
import { Button, Card, Empty, Modal, Popconfirm, Space, Tag, Tooltip } from 'antd';
import { DeleteOutlined, EyeOutlined, PictureOutlined, VideoCameraOutlined } from '@ant-design/icons';
import type { PrivatePortraitAsset } from '../../../types';
const statusColor: Record<string, string> = {
@@ -12,11 +12,20 @@ const statusColor: Record<string, string> = {
delete_failed: 'red',
};
const statusText: Record<string, string> = {
Active: '入库成功',
Processing: '入库处理中',
Failed: '入库失败',
local_deleted: '本地已删除',
remote_deleted: '远程已删除',
delete_failed: '删除失败',
};
interface Props {
items: PrivatePortraitAsset[];
loading?: boolean;
onSync: (assetId: string) => void;
onDelete: (assetId: string) => void;
onRefresh?: () => void;
}
const buildPreviewUrl = (url?: string | null) => {
@@ -39,53 +48,114 @@ const formatDuration = (value?: number | null) => {
return `${duration.toFixed(duration >= 10 ? 0 : 1)}s`;
};
const PrivatePortraitAssetGrid: React.FC<Props> = ({ items, onSync, onDelete }) => {
if (!items.length) return <Empty description="暂无真人素材" />;
const PrivatePortraitAssetGrid: React.FC<Props> = ({ items, onDelete, onRefresh }) => {
const pollingRef = useRef<number | null>(null);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewUrl, setPreviewUrl] = useState('');
const [previewType, setPreviewType] = useState<'Image' | 'Video'>('Image');
useEffect(() => {
const needsPolling = items.some(
(item) => item.status !== 'Failed' && item.status !== 'Active'
);
if (needsPolling && onRefresh) {
if (!pollingRef.current) {
pollingRef.current = window.setInterval(() => {
onRefresh();
}, 3000);
}
} else {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
}
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
};
}, [items, onRefresh]);
const openPreview = (asset: PrivatePortraitAsset) => {
const url = getAssetPreviewUrl(asset);
if (!url) return;
setPreviewUrl(url);
setPreviewType(asset.assetType === 'Video' ? 'Video' : 'Image');
setPreviewOpen(true);
};
if (!items.length) return <Empty description="暂无素材" />;
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 14 }}>
{items.map((item) => {
const isVideo = item.assetType === 'Video';
const previewUrl = getAssetPreviewUrl(item);
return (
<div key={item.id} style={{ border: '1px solid #e2e8f0', borderRadius: 12, overflow: 'hidden', background: '#fff' }}>
<div style={{ height: 150, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
{previewUrl ? (
isVideo ? (
<video src={previewUrl} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<img src={previewUrl} alt={item.name || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
)
) : isVideo ? (
<VideoCameraOutlined style={{ fontSize: 32, color: '#94a3b8' }} />
) : (
<PictureOutlined style={{ fontSize: 32, color: '#94a3b8' }} />
)}
{isVideo && (
<div style={{ position: 'absolute', left: 8, bottom: 8, padding: '2px 6px', borderRadius: 8, background: 'rgba(15,23,42,0.72)', color: '#fff', fontSize: 12 }}>
{formatDuration(item.videoDuration)}
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 14 }}>
{items.map((item) => {
const isVideo = item.assetType === 'Video';
const previewUrl = getAssetPreviewUrl(item);
return (
<Card
key={item.id}
hoverable
bodyStyle={{ padding: 12 }}
style={{ borderRadius: 16, overflow: 'hidden', borderColor: '#eef2f7' }}
cover={(
<div style={{ height: 170, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
{previewUrl ? (
isVideo && item.videoCoverUrl ? (
<img src={previewUrl} alt={item.name || '视频封面'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : isVideo ? (
<video src={previewUrl} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<img src={previewUrl} alt={item.name || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
)
) : isVideo ? (
<VideoCameraOutlined style={{ fontSize: 42, color: '#64748b' }} />
) : (
<PictureOutlined style={{ fontSize: 42, color: '#64748b' }} />
)}
{isVideo && <Tag color="blue" style={{ position: 'absolute', left: 10, top: 10 }}></Tag>}
{previewUrl && (
<Button size="small" shape="circle" icon={<EyeOutlined />} style={{ position: 'absolute', right: 10, top: 10 }} onClick={() => openPreview(item)} />
)}
{isVideo && (
<div style={{ position: 'absolute', left: 10, bottom: 10, padding: '2px 6px', borderRadius: 8, background: 'rgba(15,23,42,0.72)', color: '#fff', fontSize: 12 }}>
{formatDuration(item.videoDuration)}
</div>
)}
</div>
)}
</div>
<div style={{ padding: 10 }}>
<Tooltip title={item.name || item.remoteAssetId}>
<div style={{ fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.name || item.remoteAssetId}</div>
</Tooltip>
<Space wrap size={4} style={{ marginTop: 8 }}>
<Tag color={statusColor[item.status] || 'default'}>{item.status}</Tag>
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Tooltip title={item.name || item.remoteAssetId || item.id}>
<div style={{ fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.name || item.remoteAssetId || '未命名素材'}</div>
</Tooltip>
<Space wrap size={4}>
<Tag color={statusColor[item.status] || 'default'}>{statusText[item.status] || item.status}</Tag>
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
</Space>
<Space size={6} wrap>
<Popconfirm title="确认删除这个素材吗?" onConfirm={() => onDelete(item.id)}>
<Button size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
</Space>
{item.errorMessage && <div style={{ color: '#ef4444', fontSize: 12, marginTop: 6 }}>{item.errorMessage}</div>}
<Space style={{ marginTop: 10 }} size={6}>
<Button size="small" icon={<ReloadOutlined />} onClick={() => onSync(item.id)}></Button>
<Popconfirm title="确认删除这个素材吗?" onConfirm={() => onDelete(item.id)}>
<Button size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
</div>
</div>
);
})}
</div>
</Card>
);
})}
</div>
<Modal title="素材预览" open={previewOpen} onCancel={() => setPreviewOpen(false)} footer={null} width={760} destroyOnClose>
<div style={{ minHeight: 420, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0f172a', borderRadius: 12, overflow: 'hidden' }}>
{previewType === 'Video' ? (
<video src={previewUrl} controls autoPlay style={{ maxWidth: '100%', maxHeight: 520 }} />
) : (
<img src={previewUrl} alt="素材预览" style={{ maxWidth: '100%', maxHeight: 520, objectFit: 'contain' }} />
)}
</div>
</Modal>
</>
);
};
@@ -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();
@@ -1,10 +1,12 @@
import React, { useMemo, useState } from 'react';
import { Tabs, Typography } from 'antd';
import React, { useMemo, useState, useEffect } from 'react';
import { Card, Col, Row, Tabs, Typography, message } from 'antd';
import { useSearchParams } from 'react-router-dom';
import { getPrivatePortraitConfig, getPrivatePortraitProjects, getPrivatePortraitVirtualConfig, getPrivatePortraitVirtualProjects } from '../../../api';
import type { PrivatePortraitConfig, PrivatePortraitProject } from '../../../types';
import RealPersonLibraryPanel from './RealPersonLibraryPanel';
import VirtualMaterialPanel from './VirtualMaterialPanel';
const { Title, Text } = Typography;
const { Title, Text, Paragraph } = Typography;
type PrivatePortraitTabKey = 'real_person' | 'aigc_virtual';
@@ -15,6 +17,48 @@ const normalizeTabKey = (value?: string | null): PrivatePortraitTabKey => (
const PrivatePortraitLibraryPanel: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [activeKey, setActiveKey] = useState<PrivatePortraitTabKey>(() => normalizeTabKey(searchParams.get('portraitTab')));
const [config, setConfig] = useState<PrivatePortraitConfig | null>(null);
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string>();
useEffect(() => {
const loadData = async () => {
try {
if (activeKey === 'aigc_virtual') {
const [configRes, projectsRes] = await Promise.all([
getPrivatePortraitVirtualConfig(),
getPrivatePortraitVirtualProjects({ page: 1, pageSize: 100, status: 'active' }),
]);
setConfig(configRes);
const projectList = projectsRes.items || [];
setProjects(projectList);
setSelectedProjectId((prev) => prev && projectList.some((item) => item.id === prev) ? prev : projectList[0]?.id);
} else {
const [configRes, projectsRes] = await Promise.all([
getPrivatePortraitConfig(),
getPrivatePortraitProjects({ pageSize: 100, status: 'active' }),
]);
setConfig(configRes);
const projectList = projectsRes.items || [];
setProjects(projectList);
setSelectedProjectId((prev) => prev && projectList.some((item) => item.id === prev) ? prev : projectList[0]?.id);
}
} catch (err: any) {
message.error(err?.message || '加载数据失败');
}
};
void loadData();
}, [activeKey]);
const selectedProject = useMemo(
() => projects.find((item) => item.id === selectedProjectId) || null,
[projects, selectedProjectId],
);
const quotaText = useMemo(() => {
if (!config) return '额度加载中';
return `已用 ${config.usedAssetCount || 0} / ${config.assetLimit || 0} 个素材,剩余 ${config.remainingAssetCount || 0}`;
}, [config]);
const items = useMemo(() => [
{
@@ -45,6 +89,33 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
<Title level={4} style={{ margin: 0 }}></Title>
<Text type="secondary"> AIGC Asset Group</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>
<div style={{ fontSize: 24, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>//</Paragraph>
</Card>
</Col>
<Col xs={24} md={8}>
<Card style={{ borderRadius: 16 }}>
<Text type="secondary"></Text>
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{projects.length}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>
{activeKey === 'aigc_virtual'
? '虚拟人像项目会同步创建火山 AIGC Asset Group。'
: '真人项目组需完成人脸认证后才可上传素材。'}
</Paragraph>
</Card>
</Col>
<Col xs={24} md={8}>
<Card style={{ borderRadius: 16 }}>
<Text type="secondary"></Text>
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{selectedProject?.assetCount || 0}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}> Active AI </Paragraph>
</Card>
</Col>
</Row>
<Tabs
activeKey={activeKey}
onChange={handleTabChange}
@@ -1,8 +1,8 @@
import React, { useEffect, useState } from 'react';
import { Button, Card, Popconfirm, Space, Tag, Typography, message } from 'antd';
import { DeleteOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons';
import { Button, Card, Empty, Input, Pagination, Popconfirm, Select, Space, Spin, Typography, message } from 'antd';
import { DeleteOutlined, UploadOutlined } from '@ant-design/icons';
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types';
import { deletePrivatePortraitAsset, deletePrivatePortraitProject, getPrivatePortraitAssets, syncPrivatePortraitAsset } from '../../../api';
import { deletePrivatePortraitAsset, deletePrivatePortraitProject, getPrivatePortraitAssets } from '../../../api';
import PrivatePortraitAssetGrid from './AssetGrid';
import PrivatePortraitAssetUpload from './AssetUpload';
@@ -16,12 +16,27 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
const [loading, setLoading] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
const [keyword, setKeyword] = useState('');
const [assetStatus, setAssetStatus] = useState<string>();
const [assetType, setAssetType] = useState<string>();
const [assetPage, setAssetPage] = useState(1);
const [assetPageSize, setAssetPageSize] = useState(20);
const [assetTotal, setAssetTotal] = useState(0);
const loadAssets = async () => {
const loadAssets = async (page = assetPage, pageSize = assetPageSize) => {
setLoading(true);
try {
const res = await getPrivatePortraitAssets(project.id, { pageSize: 100 });
const res = await getPrivatePortraitAssets(project.id, {
page,
pageSize,
keyword,
status: assetStatus,
assetType: assetType as any,
});
setAssets(res.items);
setAssetTotal(res.total || 0);
setAssetPage(page);
setAssetPageSize(pageSize);
} catch (e: any) {
message.error(e?.message || '加载素材失败');
} finally {
@@ -29,23 +44,12 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
}
};
useEffect(() => { loadAssets(); }, [project.id]);
const handleSync = async (assetId: string) => {
try {
await syncPrivatePortraitAsset(assetId);
await loadAssets();
onChanged();
message.success('素材状态已刷新');
} catch (e: any) {
message.error(e?.message || '刷新失败');
}
};
useEffect(() => { loadAssets(1, assetPageSize); }, [project.id, keyword, assetStatus, assetType]);
const handleDeleteAsset = async (assetId: string) => {
try {
await deletePrivatePortraitAsset(assetId);
await loadAssets();
await loadAssets(assetPage, assetPageSize);
onChanged();
message.success('素材已删除');
} catch (e: any) {
@@ -67,26 +71,76 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
return (
<Card
title={<Space><span>{project.name}</span><Tag color={canUpload ? 'green' : 'processing'}>{project.status}</Tag></Space>}
title={<Space><span>{project.name}</span></Space>}
extra={(
<Space>
<Button type="primary" icon={<UploadOutlined />} disabled={!canUpload} onClick={() => setUploadOpen(true)}></Button>
<Button icon={<ReloadOutlined />} onClick={loadAssets} loading={loading}></Button>
<Popconfirm title="确认删除这个真人素材项目组吗?" onConfirm={handleDeleteProject}>
<Button danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
)}
style={{ borderRadius: 12 }}
style={{ borderRadius: 16 }}
>
<Typography.Paragraph style={{ color: '#64748b' }}>{project.description || '暂无描述'}</Typography.Paragraph>
{!canUpload && (
<Typography.Paragraph style={{ color: '#f97316' }}>
</Typography.Paragraph>
)}
<PrivatePortraitAssetGrid items={assets} loading={loading} onSync={handleSync} onDelete={handleDeleteAsset} />
<PrivatePortraitAssetUpload projectId={project.id} open={uploadOpen} onClose={() => setUploadOpen(false)} onSuccess={() => { loadAssets(); onChanged(); }} />
<Space style={{ width: '100%', marginBottom: 16 }} wrap>
<Input.Search
allowClear
placeholder="搜索素材名称"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onSearch={() => loadAssets(1, assetPageSize)}
style={{ width: 240 }}
/>
<Select
allowClear
placeholder="素材状态"
value={assetStatus}
onChange={(value) => setAssetStatus(value)}
style={{ width: 150 }}
options={[
{ value: 'Processing', label: '处理中' },
{ value: 'Active', label: '可用' },
{ value: 'Failed', label: '失败' },
]}
/>
<Select
allowClear
placeholder="素材类型"
value={assetType}
onChange={(value) => setAssetType(value)}
style={{ width: 130 }}
options={[
{ value: 'Image', label: '图片' },
{ value: 'Video', label: '视频' },
]}
/>
<Button onClick={() => loadAssets(1, assetPageSize)}></Button>
</Space>
<Spin spinning={loading}>
{assets.length === 0 ? (
<Empty description="暂无素材,上传图片/视频后会异步入库" style={{ marginTop: 80 }} />
) : (
<>
<PrivatePortraitAssetGrid items={assets} loading={loading} onDelete={handleDeleteAsset} onRefresh={() => loadAssets(assetPage, assetPageSize)} />
<div style={{ textAlign: 'right', marginTop: 16 }}>
<Pagination
current={assetPage}
pageSize={assetPageSize}
total={assetTotal}
showSizeChanger
showTotal={(value) => `${value} 个素材`}
onChange={(page, size) => loadAssets(page, size)}
/>
</div>
</>
)}
</Spin>
<PrivatePortraitAssetUpload projectId={project.id} open={uploadOpen} onClose={() => setUploadOpen(false)} onSuccess={() => { loadAssets(1, assetPageSize); onChanged(); }} />
</Card>
);
};
@@ -1,7 +1,9 @@
import React from 'react';
import { Button, Empty, List, Tag } from 'antd';
import { Empty, Space, Tag, Typography } from 'antd';
import type { PrivatePortraitProject } from '../../../types';
const { Text } = Typography;
interface Props {
items: PrivatePortraitProject[];
selectedId?: string | null;
@@ -11,24 +13,40 @@ interface Props {
const PrivatePortraitProjectList: React.FC<Props> = ({ items, selectedId, onSelect }) => {
if (!items.length) return <Empty description="暂无项目组" />;
return (
<List
dataSource={items}
renderItem={(item) => (
<List.Item style={{ padding: 0, marginBottom: 8 }}>
<Button
block
onClick={() => onSelect(item)}
style={{ height: 'auto', padding: 12, textAlign: 'left', borderColor: selectedId === item.id ? '#8b5cf6' : '#e2e8f0' }}
<Space direction="vertical" style={{ width: '100%' }} size={10}>
{items.map((project) => {
const active = selectedId === project.id;
return (
<div
key={project.id}
onClick={() => onSelect(project)}
style={{
padding: 14,
borderRadius: 14,
cursor: 'pointer',
border: active ? '1px solid #8b5cf6' : '1px solid #e2e8f0',
background: active ? '#f5f3ff' : '#fff',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
<strong>{item.name}</strong>
<Tag color={item.activeAssetCount > 0 ? 'green' : 'default'}>{item.activeAssetCount}/{item.assetCount}</Tag>
</div>
{item.description && <div style={{ color: '#64748b', fontSize: 12, marginTop: 4 }}>{item.description}</div>}
</Button>
</List.Item>
)}
/>
<Space style={{ width: '100%', justifyContent: 'space-between' }} align="start">
<div style={{ minWidth: 0 }}>
<Text strong ellipsis style={{ display: 'block' }}>{project.name}</Text>
{project.description && <Text type="secondary" ellipsis style={{ display: 'block', fontSize: 12 }}>{project.description}</Text>}
</div>
{/* <Tag color={project.status === 'active' ? 'green' : 'processing'}>
{project.status === 'active' ? '可用' : project.status}
</Tag> */}
</Space>
<Space wrap size={4} style={{ marginTop: 10 }}>
<Tag> {project.assetCount || 0}</Tag>
<Tag color="green"> {project.imageAssetCount || 0}</Tag>
<Tag color="blue"> {project.videoAssetCount || 0}</Tag>
{/* <Tag color="success">Active {project.activeAssetCount || 0}</Tag> */}
</Space>
</div>
);
})}
</Space>
);
};
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Card, Col, Form, Input, Modal, QRCode, Row, Space, Spin, Typography, message } from 'antd';
import { Button, Card, Col, Empty, Form, Input, Modal, QRCode, Row, Space, Spin, Typography, message } from 'antd';
import { CheckCircleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import type { PrivatePortraitProject, PrivatePortraitValidateSession } from '../../../types';
import { createPrivatePortraitProject, getPrivatePortraitProjects, getPrivatePortraitValidateSession } from '../../../api';
@@ -117,27 +117,36 @@ const RealPersonLibraryPanel: React.FC = () => {
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={loadProjects} loading={loading}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>
</Space>
<div style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
<Row gutter={16}>
<Col xs={24} md={7} lg={6}>
<Card title="项目组" style={{ borderRadius: 12 }}>
<PrivatePortraitProjectList items={projects} selectedId={selected?.id} onSelect={setSelected} />
<Col xs={24} lg={7}>
<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>
)}
style={{ borderRadius: 16, minHeight: 520 }}
>
<Spin spinning={loading}>
{projects.length === 0 ? (
<Empty description="暂无项目组" />
) : (
<PrivatePortraitProjectList items={projects} selectedId={selected?.id} onSelect={setSelected} />
)}
</Spin>
</Card>
</Col>
<Col xs={24} md={17} lg={18}>
<Col xs={24} lg={17}>
{selected ? (
<PrivatePortraitProjectDetail project={selected} onDeleted={() => { setSelected(null); loadProjects(); }} onChanged={loadProjects} />
) : (
<Card style={{ borderRadius: 12, textAlign: 'center', color: '#94a3b8' }}></Card>
<Card style={{ borderRadius: 16, minHeight: 520, textAlign: 'center', color: '#94a3b8' }}></Card>
)}
</Col>
</Row>
@@ -178,7 +187,7 @@ const RealPersonLibraryPanel: React.FC = () => {
{isSuccess ? '认证成功,项目组正在刷新' : '请使用手机扫码完成人脸认证,成功后回到电脑端查看项目组。'}
</Typography.Text>
</div>
{h5Link && !isSuccess && <Typography.Text copyable style={{ wordBreak: 'break-all' }}>{h5Link}</Typography.Text>}
{/* {h5Link && !isSuccess && <Typography.Text copyable style={{ wordBreak: 'break-all' }}>{h5Link}</Typography.Text>} */}
</Space>
)}
</Modal>
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
App,
Button,
@@ -21,12 +21,10 @@ import {
} from 'antd';
import type { UploadFile } from 'antd/es/upload/interface';
import {
CloudSyncOutlined,
DeleteOutlined,
EyeOutlined,
PictureOutlined,
PlusOutlined,
ReloadOutlined,
UploadOutlined,
VideoCameraOutlined,
} from '@ant-design/icons';
@@ -36,15 +34,13 @@ import {
deletePrivatePortraitVirtualAsset,
deletePrivatePortraitVirtualProject,
getPrivatePortraitVirtualAssets,
getPrivatePortraitVirtualConfig,
getPrivatePortraitVirtualProjects,
syncPrivatePortraitVirtualAsset,
uploadImage,
uploadVideo,
uploadPrivatePortraitVirtualImage,
uploadPrivatePortraitVirtualVideo,
} from '../../../api';
import type { PrivatePortraitAsset, PrivatePortraitConfig, PrivatePortraitProject } from '../../../types';
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types';
const { Text, Paragraph } = Typography;
const { Text } = Typography;
type AssetTypeFilter = 'Image' | 'Video' | undefined;
@@ -53,39 +49,18 @@ const MAX_PRIVATE_VIDEO_DURATION = 15;
const statusConfig: Record<string, { label: string; color: string }> = {
creating: { label: '本地创建中', color: 'processing' },
Processing: { label: '火山处理中', color: 'processing' },
Active: { label: '可用于生成', color: 'success' },
Processing: { label: '入库处理中', color: 'processing' },
Active: { label: '入库成功', color: 'success' },
Failed: { label: '入库失败', color: 'error' },
local_deleted: { label: '本地已删', color: 'default' },
remote_deleted: { label: '远端已删', color: 'default' },
delete_failed: { label: '远端删除失败', color: 'error' },
};
const assetTypeConfig: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
Image: { label: '图片', color: 'green', icon: <PictureOutlined /> },
Video: { label: '视频', color: 'blue', icon: <VideoCameraOutlined /> },
};
const formatDateTime = (dateStr?: string | null) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
if (Number.isNaN(date.getTime())) return '-';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
const formatSize = (size?: number | null) => {
const value = Number(size || 0);
if (!value) return '-';
if (value >= 1024 * 1024 * 1024) return `${(value / 1024 / 1024 / 1024).toFixed(2)} GB`;
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(2)} MB`;
if (value >= 1024) return `${(value / 1024).toFixed(2)} KB`;
return `${value} B`;
const formatDuration = (value?: number | null) => {
const duration = Number(value || 0);
if (!Number.isFinite(duration) || duration <= 0) return '-';
return `${duration.toFixed(duration >= 10 ? 0 : 1)}s`;
};
const buildPreviewUrl = (url?: string | null) => {
@@ -154,15 +129,8 @@ const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
return <Tag color={config?.color || 'default'}>{config?.label || value}</Tag>;
};
const TypeTag: React.FC<{ type?: string | null }> = ({ type }) => {
const value = type || '-';
const config = assetTypeConfig[value];
return <Tag color={config?.color || 'default'} icon={config?.icon}>{config?.label || value}</Tag>;
};
const VirtualMaterialPanel: React.FC = () => {
const { message } = App.useApp();
const [config, setConfig] = useState<PrivatePortraitConfig | null>(null);
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string>();
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
@@ -184,26 +152,13 @@ const VirtualMaterialPanel: React.FC = () => {
const [previewUrl, setPreviewUrl] = useState('');
const [previewType, setPreviewType] = useState<'Image' | 'Video'>('Image');
const [createForm] = Form.useForm<{ name: string; description?: string }>();
const pollingRef = useRef<number | null>(null);
const selectedProject = useMemo(
() => projects.find((item) => item.id === selectedProjectId) || null,
[projects, selectedProjectId],
);
const quotaText = useMemo(() => {
if (!config) return '额度加载中';
return `已用 ${config.usedAssetCount || 0} / ${config.assetLimit || 0} 个素材,剩余 ${config.remainingAssetCount || 0}`;
}, [config]);
const loadConfig = async () => {
try {
const next = await getPrivatePortraitVirtualConfig();
setConfig(next);
} catch (err: any) {
message.error(err?.message || '加载私域素材额度失败');
}
};
const loadProjects = async () => {
setProjectLoading(true);
try {
@@ -245,7 +200,7 @@ const VirtualMaterialPanel: React.FC = () => {
};
const reloadAll = async () => {
await Promise.all([loadConfig(), loadProjects()]);
await loadProjects();
};
useEffect(() => {
@@ -256,6 +211,32 @@ const VirtualMaterialPanel: React.FC = () => {
if (selectedProjectId) loadAssets(1, assetPageSize);
}, [selectedProjectId]);
useEffect(() => {
const needsPolling = assets.some(
(asset) => asset.status !== 'Failed' && asset.status !== 'Active'
);
if (needsPolling && selectedProjectId) {
if (!pollingRef.current) {
pollingRef.current = window.setInterval(() => {
loadAssets(assetPage, assetPageSize);
}, 3000);
}
} else {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
}
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
};
}, [assets, selectedProjectId, assetPage, assetPageSize]);
const handleCreateProject = async () => {
const values = await createForm.validateFields();
setCreatingProject(true);
@@ -295,20 +276,21 @@ 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);
setFileList([]);
setAssetName('');
await Promise.all([loadConfig(), loadProjects(), loadAssets(1, assetPageSize)]);
await Promise.all([loadProjects(), loadAssets(1, assetPageSize)]);
} catch (err: any) {
message.error(err?.message || '上传素材失败');
} finally {
@@ -316,21 +298,11 @@ const VirtualMaterialPanel: React.FC = () => {
}
};
const handleSyncAsset = async (assetId: string) => {
try {
await syncPrivatePortraitVirtualAsset(assetId);
message.success('素材状态已刷新');
await Promise.all([loadConfig(), loadProjects(), loadAssets(assetPage, assetPageSize)]);
} catch (err: any) {
message.error(err?.message || '刷新素材状态失败');
}
};
const handleDeleteAsset = async (assetId: string) => {
try {
await deletePrivatePortraitVirtualAsset(assetId);
message.success('素材已删除,远端删除将异步执行');
await Promise.all([loadConfig(), loadProjects(), loadAssets(assetPage, assetPageSize)]);
await Promise.all([loadProjects(), loadAssets(assetPage, assetPageSize)]);
} catch (err: any) {
message.error(err?.message || '删除素材失败');
}
@@ -370,36 +342,40 @@ const VirtualMaterialPanel: React.FC = () => {
style={{ borderRadius: 16, overflow: 'hidden', borderColor: '#eef2f7' }}
cover={(
<div style={{ height: 170, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
{preview && !isVideo ? (
<img src={preview} alt={asset.name || '虚拟人像素材'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : preview && isVideo && asset.videoCoverUrl ? (
<img src={preview} alt={asset.name || '视频封面'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
{preview ? (
isVideo && asset.videoCoverUrl ? (
<img src={preview} alt={asset.name || '视频封面'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : isVideo ? (
<video src={preview} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<img src={preview} alt={asset.name || '虚拟人像素材'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
)
) : isVideo ? (
<VideoCameraOutlined style={{ fontSize: 42, color: '#64748b' }} />
) : (
<PictureOutlined style={{ fontSize: 42, color: '#64748b' }} />
)}
{isVideo && <Tag color="blue" style={{ position: 'absolute', left: 10, top: 10 }}></Tag>}
<Button size="small" shape="circle" icon={<EyeOutlined />} style={{ position: 'absolute', right: 10, top: 10 }} onClick={() => openPreview(asset)} />
{preview && (
<Button size="small" shape="circle" icon={<EyeOutlined />} style={{ position: 'absolute', right: 10, top: 10 }} onClick={() => openPreview(asset)} />
)}
{isVideo && (
<div style={{ position: 'absolute', left: 10, bottom: 10, padding: '2px 6px', borderRadius: 8, background: 'rgba(15,23,42,0.72)', color: '#fff', fontSize: 12 }}>
{formatDuration(asset.videoDuration)}
</div>
)}
</div>
)}
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Tooltip title={asset.name || asset.remoteAssetId || asset.id}>
<Text strong ellipsis style={{ display: 'block' }}>{asset.name || asset.remoteAssetId || '未命名素材'}</Text>
<div style={{ fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{asset.name || asset.remoteAssetId || '未命名素材'}</div>
</Tooltip>
<Space wrap size={4}>
<TypeTag type={asset.assetType} />
<StatusTag status={asset.status} />
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
</Space>
<div style={{ color: '#64748b', fontSize: 12, lineHeight: 1.7 }}>
<div>{formatSize(asset.fileSize)}</div>
<div>{asset.pollCount || 0} </div>
<div>{formatDateTime(asset.createdAt)}</div>
</div>
{asset.errorMessage && <div style={{ color: '#ef4444', fontSize: 12 }}>{asset.errorMessage}</div>}
<Space size={6} wrap>
<Button size="small" icon={<CloudSyncOutlined />} onClick={() => handleSyncAsset(asset.id)}></Button>
<Popconfirm title="确认删除这个虚拟人像素材吗?" onConfirm={() => handleDeleteAsset(asset.id)}>
<Button size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
@@ -411,30 +387,6 @@ const VirtualMaterialPanel: React.FC = () => {
return (
<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>
<div style={{ fontSize: 24, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>//</Paragraph>
</Card>
</Col>
<Col xs={24} md={8}>
<Card style={{ borderRadius: 16 }}>
<Text type="secondary"></Text>
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{projects.length}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}> AIGC Asset Group</Paragraph>
</Card>
</Col>
<Col xs={24} md={8}>
<Card style={{ borderRadius: 16 }}>
<Text type="secondary"></Text>
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{selectedProject?.assetCount || 0}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}> Active AI </Paragraph>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} lg={7}>
<Card
@@ -466,13 +418,13 @@ const VirtualMaterialPanel: React.FC = () => {
<Text strong ellipsis style={{ display: 'block' }}>{project.name}</Text>
{project.description && <Text type="secondary" ellipsis style={{ display: 'block', fontSize: 12 }}>{project.description}</Text>}
</div>
<StatusTag status={project.status} />
{/* <StatusTag status={project.status} /> */}
</Space>
<Space wrap size={4} style={{ marginTop: 10 }}>
<Tag> {project.assetCount || 0}</Tag>
<Tag color="green"> {project.imageAssetCount || 0}</Tag>
<Tag color="blue"> {project.videoAssetCount || 0}</Tag>
<Tag color="success">Active {project.activeAssetCount || 0}</Tag>
{/* <Tag color="success">Active {project.activeAssetCount || 0}</Tag> */}
</Space>
</div>
);
@@ -488,7 +440,7 @@ const VirtualMaterialPanel: React.FC = () => {
title={selectedProject ? selectedProject.name : '素材资产'}
extra={(
<Space wrap>
<Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(assetPage, assetPageSize); }} loading={assetLoading}></Button>
{/* <Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(assetPage, assetPageSize); }} loading={assetLoading}>刷新</Button> */}
<Button type="primary" icon={<UploadOutlined />} disabled={!selectedProjectId} onClick={() => setUploadOpen(true)}>/</Button>
{selectedProjectId && (
<Popconfirm title="确认删除当前虚拟人像项目组吗?" onConfirm={handleDeleteProject}>
@@ -20,6 +20,13 @@ interface PrivatePortraitAssetPickerProps {
maxCount?: number;
onClose: () => void;
onSelect: (assets: PrivatePortraitSelectableAsset[]) => void;
maxImageCount?: number;
maxVideoCount?: number;
usedImageCount?: number;
usedVideoCount?: number;
usedVideoDuration?: number;
maxVideoDuration?: number;
accept?: string;
}
const libraryMeta: Record<PrivatePortraitLibraryType, { title: string; empty: string; projectError: string; assetError: string; fallbackName: string }> = {
@@ -68,12 +75,19 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
maxCount = 20,
onClose,
onSelect,
maxImageCount,
maxVideoCount,
usedImageCount,
usedVideoCount,
usedVideoDuration,
maxVideoDuration,
accept,
}) => {
const meta = libraryMeta[libraryType];
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [projectId, setProjectId] = useState<string | undefined>();
const [keyword, setKeyword] = useState('');
const [assetType, setAssetType] = useState<AssetTypeFilter>();
const [assetType, setAssetType] = useState<AssetTypeFilter>(accept === 'image/*' ? 'Image' : undefined);
const [assets, setAssets] = useState<PrivatePortraitSelectableAsset[]>([]);
const [selectedAssets, setSelectedAssets] = useState<Map<string, PrivatePortraitSelectableAsset>>(new Map());
const [loadingProjects, setLoadingProjects] = useState(false);
@@ -89,8 +103,8 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
const next = res.items || [];
setProjects(next);
setProjectId((prev) => (prev && next.some((item) => item.id === prev) ? prev : next[0]?.id));
} catch (err: any) {
message.error(err?.message || meta.projectError);
} catch (err: unknown) {
message.error((err as { message?: string })?.message || meta.projectError);
} finally {
setLoadingProjects(false);
}
@@ -108,8 +122,8 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
pageSize: 100,
});
setAssets(res.items || []);
} catch (err: any) {
message.error(err?.message || meta.assetError);
} catch (err: unknown) {
message.error((err as { message?: string })?.message || meta.assetError);
} finally {
setLoadingAssets(false);
}
@@ -117,24 +131,32 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
useEffect(() => {
if (!open) return;
setSelectedAssets(new Map());
setKeyword('');
setAssetType(undefined);
setProjectId(undefined);
setAssets([]);
loadProjects();
setTimeout(() => {
setSelectedAssets(new Map());
setKeyword('');
setAssetType(undefined);
setProjectId(undefined);
setAssets([]);
loadProjects();
}, 0);
}, [open, libraryType]);
useEffect(() => {
if (!open) return;
loadAssets();
}, [open, projectId, assetType]);
setTimeout(() => {
loadAssets();
}, 0);
}, [open, projectId, assetType, libraryType]);
const toggle = (asset: PrivatePortraitSelectableAsset) => {
if (selectedIds.includes(asset.id)) {
message.info('该素材已添加');
return;
}
if (accept === 'image/*' && asset.assetType === 'Video') {
message.warning('当前仅支持选择图片素材');
return;
}
setSelectedAssets((prev) => {
const next = new Map(prev);
if (next.has(asset.id)) {
@@ -150,6 +172,20 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
});
};
const isExceeded = useMemo(() => {
const selectedImages = Array.from(selectedAssets.values()).filter(a => a.assetType === 'Image').length;
const selectedVideos = Array.from(selectedAssets.values()).filter(a => a.assetType === 'Video').length;
const selectedVideoDuration = Array.from(selectedAssets.values())
.filter(a => a.assetType === 'Video')
.reduce((sum, a) => sum + (a.videoDuration || 0), 0);
const maxAvailableImages = maxImageCount !== undefined && usedImageCount !== undefined ? maxImageCount - usedImageCount : Infinity;
const maxAvailableVideos = maxVideoCount !== undefined && usedVideoCount !== undefined ? maxVideoCount - usedVideoCount : Infinity;
const maxAvailableDuration = maxVideoDuration !== undefined && usedVideoDuration !== undefined ? maxVideoDuration - usedVideoDuration : Infinity;
return selectedImages > maxAvailableImages || selectedVideos > maxAvailableVideos || selectedVideoDuration > maxAvailableDuration;
}, [selectedAssets, maxImageCount, usedImageCount, maxVideoCount, usedVideoCount, maxVideoDuration, usedVideoDuration]);
const confirm = () => {
const selected = (Array.from(selectedAssets.values()) as PrivatePortraitSelectableAsset[]).filter((item) => !selectedIds.includes(item.id));
if (!selected.length) {
@@ -162,20 +198,54 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
return (
<Modal
title={meta.title}
title={`${meta.title}`}
open={open}
onCancel={onClose}
width={920}
destroyOnHidden
footer={[
<Button key="cancel" onClick={onClose}></Button>,
<Button key="ok" type="primary" onClick={confirm} style={{ background: '#8b5cf6' }}>
<Button key="ok" type="primary" onClick={confirm} disabled={isExceeded} style={{
background: isExceeded ? '#94a3b8' : '#8b5cf6',
opacity: isExceeded ? 0.6 : 1,
cursor: isExceeded ? 'not-allowed' : 'pointer',
}}>
{selectedAssets.size}
</Button>,
]}
>
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 16, minHeight: 480 }}>
<div style={{ border: '1px solid #eef0f4', borderRadius: 12, padding: 12, background: '#fafafa' }}>
{accept !== 'image/*' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 20, marginBottom: 16, padding: '12px 16px', background: '#fafafa', borderRadius: 8 }}>
{(() => {
const selectedImages = Array.from(selectedAssets.values()).filter(a => a.assetType === 'Image').length;
const selectedVideos = Array.from(selectedAssets.values()).filter(a => a.assetType === 'Video').length;
const selectedVideoDuration = Array.from(selectedAssets.values())
.filter(a => a.assetType === 'Video')
.reduce((sum, a) => sum + (a.videoDuration || 0), 0);
const maxAvailableImages = maxImageCount !== undefined && usedImageCount !== undefined ? maxImageCount - usedImageCount : Infinity;
const maxAvailableVideos = maxVideoCount !== undefined && usedVideoCount !== undefined ? maxVideoCount - usedVideoCount : Infinity;
const maxAvailableDuration = maxVideoDuration !== undefined && usedVideoDuration !== undefined ? maxVideoDuration - usedVideoDuration : Infinity;
const imageExceeded = selectedImages > maxAvailableImages;
const videoExceeded = selectedVideos > maxAvailableVideos;
const durationExceeded = selectedVideoDuration > maxAvailableDuration;
return (
<>
<span style={{ fontSize: 13, color: imageExceeded ? '#ef4444' : '#64748b', fontWeight: imageExceeded ? 600 : 400 }}>
{selectedImages}/{maxImageCount !== undefined && usedImageCount !== undefined ? maxImageCount - usedImageCount : '-'}
</span>
<span style={{ fontSize: 13, color: videoExceeded || durationExceeded ? '#ef4444' : '#64748b', fontWeight: videoExceeded || durationExceeded ? 600 : 400 }}>
{selectedVideos}/{maxVideoCount !== undefined && usedVideoCount !== undefined ? maxVideoCount - usedVideoCount : '-'} {selectedVideoDuration.toFixed(1)}/{maxVideoDuration !== undefined && usedVideoDuration !== undefined ? (maxVideoDuration - usedVideoDuration).toFixed(1) : '-'}
</span>
</>
);
})()}
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 16, height: 500, }}>
<div style={{ border: '1px solid #eef0f4', borderRadius: 12, padding: 12, background: '#fafafa',overflowY: 'auto', height: "100%" }}>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 12 }}>
<Text strong></Text>
<Button size="small" icon={<ReloadOutlined />} onClick={loadProjects} loading={loadingProjects} />
@@ -199,9 +269,9 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
<div style={{ width: '100%' }}>
<Text strong ellipsis style={{ display: 'block' }}>{item.name}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
Active {item.activeAssetCount || 0}
{/* {item.activeAssetCount || 0} */}
{typeof item.activeImageAssetCount === 'number' || typeof item.activeVideoAssetCount === 'number'
? ` ·${item.activeImageAssetCount || 0} / 视 ${item.activeVideoAssetCount || 0}`
? `${item.activeImageAssetCount || 0} / 视 ${item.activeVideoAssetCount || 0}`
: ''}
</Text>
</div>
@@ -211,7 +281,7 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
</Spin>
</div>
<div>
<div style={{overflowY: 'auto', height: "100%" }}>
<Space style={{ width: '100%', marginBottom: 12 }}>
<Input
allowClear
@@ -222,12 +292,14 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
onPressEnter={loadAssets}
/>
<Select
allowClear
allowClear={accept !== 'image/*'}
placeholder="素材类型"
value={assetType}
onChange={setAssetType}
style={{ width: 116 }}
options={[
options={accept === 'image/*' ? [
{ value: 'Image', label: '图片' },
] : [
{ value: 'Image', label: '图片' },
{ value: 'Video', label: '视频' },
]}
@@ -239,7 +311,7 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={meta.empty} style={{ marginTop: 120 }} />
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 12, maxHeight: 420, overflowY: 'auto', paddingRight: 4 }}>
{assets.map((asset) => {
{assets.filter(asset => accept !== 'image/*' || asset.assetType === 'Image').map((asset) => {
const active = checked.has(asset.id) || selectedIds.includes(asset.id);
const disabled = selectedIds.includes(asset.id);
const previewUrl = getAssetPreviewUrl(asset);
@@ -285,7 +357,7 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
<div style={{ padding: 10 }}>
<Text strong ellipsis style={{ display: 'block' }}>{asset.name || '未命名素材'}</Text>
<Space wrap size={4} style={{ marginTop: 6 }}>
<Tag color="green">Active</Tag>
{/* <Tag color="green">Active</Tag> */}
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
<Tag>{asset.projectName}</Tag>
</Space>
@@ -218,18 +218,18 @@ const UploadResourceHistoryPanel: React.FC = () => {
<div style={{ fontSize: 18, fontWeight: 700, color: '#1e293b' }}>{group.generatedDate}</div>
<Text type="secondary"> {group.total} </Text>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(190px, 1fr))', gap: 16 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'flex-start',}}>
{group.items.map((item) => {
const checked = selectedIds.has(item.id);
return (
<div key={item.id} style={{ border: checked ? '2px solid #8b5cf6' : '1px solid #e2e8f0', borderRadius: 16, overflow: 'hidden', background: '#fff', boxShadow: '0 10px 24px rgba(15,23,42,0.06)' }}>
<div key={item.id} style={{ width: '17%', minWidth: 240, margin: 16, border: checked ? '2px solid #8b5cf6' : '1px solid #e2e8f0', borderRadius: 16, overflow: 'hidden', background: '#fff', boxShadow: '0 10px 24px rgba(15,23,42,0.06)' }}>
<div style={{ position: 'relative', background: '#f1f5f9' }}>
{renderMedia(item)}
<Checkbox checked={checked} onChange={() => toggle(item.id)} style={{ position: 'absolute', top: 10, left: 10, background: '#fff', borderRadius: 6, padding: 4 }} />
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ position: 'absolute', top: 10, right: 10, margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
</div>
<div style={{ padding: 12 }}>
<Text ellipsis title={item.fileName || item.id} style={{ display: 'block', fontWeight: 600, color: '#334155' }}>{item.fileName || item.id}</Text>
<div style={{ padding: 12 ,paddingTop: 0}}>
{/* <Text ellipsis title={item.fileName || item.id} style={{ display: 'block', fontWeight: 600, color: '#334155' }}>{item.fileName || item.id}</Text> */}
<Space size={4} wrap style={{ marginTop: 8 }}>
<Tag style={{ margin: 0 }}>{item.moduleLabel}</Tag>
<Tag style={{ margin: 0 }}>{bytesText(item.fileSizeBytes)}</Tag>
@@ -246,7 +246,7 @@ const UploadResourceHistoryPanel: React.FC = () => {
})}
</div>
{group.items.length < group.total && (
<div style={{ textAlign: 'center', marginTop: 14 }}>
<div style={{ textAlign: 'left', marginTop: 14 }}>
<Button onClick={() => handleLoadMoreDay(group)}></Button>
</div>
)}
@@ -260,7 +260,11 @@ const UploadResourceHistoryPanel: React.FC = () => {
<Pagination current={page} pageSize={pageSize} total={totalDays} showSizeChanger pageSizeOptions={[5, 10]} onChange={(nextPage, nextSize) => { setPage(nextPage); setPageSize(nextSize); }} />
</div>
<Modal open={!!previewItem} title={previewItem?.fileName || '预览'} footer={null} width={900} centered destroyOnHidden onCancel={() => setPreviewItem(null)}>
<Modal open={!!previewItem}
// title={previewItem?.fileName || '预览'}
title={'预览'}
footer={null} width={900} centered destroyOnHidden onCancel={() => setPreviewItem(null)}>
{previewItem ? renderMedia(previewItem, 'preview') : null}
</Modal>
</div>
@@ -177,73 +177,74 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
{items.length === 0 ? (
<Empty description="暂无可复用的历史上传素材" style={{ padding: '48px 0' }} />
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14, minHeight: 260 }}>
{items.map((item) => {
const checked = checkedMap.has(item.id);
const disabled = selectedIdSet.has(item.id);
const preview = buildPreviewUrl(item.previewUrl || item.displayUrl || item.resourceUrl);
return (
<div
key={item.id}
onClick={() => !disabled && toggle(item)}
style={{
position: 'relative',
borderRadius: 14,
border: checked ? '2px solid #8b5cf6' : '1px solid #e2e8f0',
background: disabled ? '#f8fafc' : '#fff',
opacity: disabled ? 0.55 : 1,
cursor: disabled ? 'not-allowed' : 'pointer',
overflow: 'hidden',
boxShadow: checked ? '0 10px 24px rgba(139, 92, 246, 0.18)' : '0 6px 18px rgba(15,23,42,0.06)',
}}
>
<div style={{ height: 112, background: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{item.resourceType === 'image' ? (
<img src={preview} alt={item.fileName || item.id} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : item.resourceType === 'video' ? (
<video src={preview} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<div style={{ width: 58, height: 58, borderRadius: 18, background: 'linear-gradient(135deg, #8b5cf6, #a78bfa)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 24 }}>
<AudioOutlined />
<div style={{ height: '500px', overflowY: 'auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14 }}>
{items.map((item) => {
const checked = checkedMap.has(item.id);
const disabled = selectedIdSet.has(item.id);
const preview = buildPreviewUrl(item.previewUrl || item.displayUrl || item.resourceUrl);
return (
<div
key={item.id}
onClick={() => !disabled && toggle(item)}
style={{
position: 'relative',
borderRadius: 14,
border: checked ? '2px solid #8b5cf6' : '1px solid #e2e8f0',
background: disabled ? '#f8fafc' : '#fff',
opacity: disabled ? 0.55 : 1,
cursor: disabled ? 'not-allowed' : 'pointer',
overflow: 'hidden',
boxShadow: checked ? '0 10px 24px rgba(139, 92, 246, 0.18)' : '0 6px 18px rgba(15,23,42,0.06)',
}}
>
<div style={{ height: 112, background: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{item.resourceType === 'image' ? (
<img src={preview} alt={item.fileName || item.id} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : item.resourceType === 'video' ? (
<video src={preview} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<div style={{ width: 58, height: 58, borderRadius: 18, background: 'linear-gradient(135deg, #8b5cf6, #a78bfa)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 24 }}>
<AudioOutlined />
</div>
)}
</div>
<div style={{ padding: 10 }}>
<Space size={6} style={{ marginBottom: 6 }}>
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
{item.durationSeconds ? <Tag style={{ margin: 0 }}>{Number(item.durationSeconds).toFixed(1)}s</Tag> : null}
</Space>
<Text ellipsis style={{ display: 'block', fontSize: 13, color: '#334155' }} title={item.fileName || item.id}>
{item.fileName || item.id}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{item.moduleLabel || '普通上传'}</Text>
</div>
{checked && (
<div style={{ position: 'absolute', top: 8, right: 8, width: 24, height: 24, borderRadius: 12, background: '#8b5cf6', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CheckOutlined />
</div>
)}
{disabled && <div style={{ position: 'absolute', top: 8, right: 8 }}><Tag></Tag></div>}
</div>
<div style={{ padding: 10 }}>
<Space size={6} style={{ marginBottom: 6 }}>
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
{item.durationSeconds ? <Tag style={{ margin: 0 }}>{Number(item.durationSeconds).toFixed(1)}s</Tag> : null}
</Space>
<Text ellipsis style={{ display: 'block', fontSize: 13, color: '#334155' }} title={item.fileName || item.id}>
{item.fileName || item.id}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{item.moduleLabel || '普通上传'}</Text>
</div>
{checked && (
<div style={{ position: 'absolute', top: 8, right: 8, width: 24, height: 24, borderRadius: 12, background: '#8b5cf6', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CheckOutlined />
</div>
)}
{disabled && <div style={{ position: 'absolute', top: 8, right: 8 }}><Tag></Tag></div>}
</div>
);
})}
);
})}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16, paddingBottom: 8 }}>
<Pagination
current={page}
pageSize={pageSize}
total={total}
showSizeChanger
pageSizeOptions={[10, 20, 50, 100]}
onChange={(nextPage, nextSize) => {
setPage(nextPage);
setPageSize(nextSize);
}}
/>
</div>
</div>
)}
</Spin>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
<Pagination
current={page}
pageSize={pageSize}
total={total}
showSizeChanger
pageSizeOptions={[10, 20, 50, 100]}
onChange={(nextPage, nextSize) => {
setPage(nextPage);
setPageSize(nextSize);
}}
/>
</div>
</Modal>
);
};
@@ -93,7 +93,7 @@ const AuthorizationWaitingPage: React.FC = () => {
<div style={{ textAlign: 'center', padding: '60px 80px', background: '#fff', borderRadius: 16, boxShadow: '0 10px 40px rgba(0,0,0,0.1)' }}>
<div style={{ width: 80, height: 80, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: '50%', display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '0 auto 24px' }}>
{isChecking && countdown < 10 ? (
<Spin size="large" tip="加载中" style={{ color: '#fff' }} />
<Spin size="large" description="加载中" style={{ color: '#fff' }} />
) : (
<ClockCircleOutlined style={{ fontSize: 40, color: '#fff' }} />
)}
File diff suppressed because it is too large Load Diff
+46 -10
View File
@@ -281,14 +281,42 @@ const GeneratePage: React.FC = () => {
const videoCount = references.filter(
(r) => r.type === "video",
).length;
if (isImage && imageCount >= 10) {
message.error("最多上传10张图片");
const videoDuration = references
.filter((r) => r.type === "video")
.reduce((sum, r) => sum + (r.duration || 0), 0);
const MAX_IMAGES = 5;
const MAX_VIDEOS = 2;
const MAX_VIDEO_DURATION = 15;
if (isImage && imageCount >= MAX_IMAGES) {
message.error(`最多上传${MAX_IMAGES}张图片`);
return false;
}
if (isVideo && videoCount >= 3) {
message.error("最多上传3个视频");
if (isVideo && videoCount >= MAX_VIDEOS) {
message.error(`最多上传${MAX_VIDEOS}个视频`);
return false;
}
let fileDuration = 0;
if (isVideo) {
fileDuration = await new Promise<number>((resolve) => {
const video = document.createElement("video");
video.preload = "metadata";
video.onloadedmetadata = () => {
resolve(video.duration || 0);
video.remove();
};
video.onerror = () => {
resolve(0);
video.remove();
};
video.src = URL.createObjectURL(file);
});
if (videoDuration + fileDuration > MAX_VIDEO_DURATION) {
message.error(`视频总时长不能超过${MAX_VIDEO_DURATION}`);
return false;
}
}
setUploading(true);
const uploadFn = isImage ? uploadImage : uploadVideo;
try {
@@ -303,6 +331,7 @@ const GeneratePage: React.FC = () => {
url: res.url,
type: isImage ? "image" : "video",
name: `${typeLabel}${typeCount}`,
duration: isVideo ? fileDuration : undefined,
},
]);
message.success(`${typeLabel}上传成功`);
@@ -1829,11 +1858,11 @@ const GeneratePage: React.FC = () => {
});
}}
onHistorySelect={(items) => {
items.forEach((item: any) => {
items.forEach((item) => {
setReferences(prev => [...prev, {
url: '',
type: item.type,
name: item.name,
url: item.resourceUrl || item.previewUrl || item.displayUrl || '',
type: item.resourceType,
name: item.fileName || '',
}]);
});
message.success(`成功添加${items.length}个历史记录`);
@@ -1842,17 +1871,24 @@ const GeneratePage: React.FC = () => {
items.forEach((item: any) => {
setReferences(prev => [...prev, {
url: item.previewUrl || '',
type: 'image',
type: item.assetType === 'Video' ? 'video' : 'image',
name: item.name || '真人素材',
source: 'private_portrait_asset',
private_asset_id: item.id,
label: '',
duration: item.assetType === 'Video' ? (item.videoDuration || 0) : undefined,
}]);
});
message.success(`已添加 ${items.length} 个真人素材参考`);
}}
uploading={uploading}
tooltipTitle={`参考内容(${references.length}/10`}
tooltipTitle={`图片${references.filter((r) => r.type === 'image').length}/5,视频${references.filter((r) => r.type === 'video').length}/2`}
maxImageCount={5}
maxVideoCount={2}
usedImageCount={references.filter((r) => r.type === 'image').length}
usedVideoCount={references.filter((r) => r.type === 'video').length}
usedVideoDuration={references.filter((r) => r.type === 'video').reduce((sum, r) => sum + (r.duration || 0), 0)}
maxVideoDuration={15}
>
<div
style={{
+1 -1
View File
@@ -917,7 +917,7 @@ const GeneratedRecord: React.FC = () => {
}));
}, [filterType, filterMedia]);
return (
<div style={{ minHeight: 'calc(100vh - 90px)', background: '#ffffffff', overflowY: 'auto' }} >
<div className="content_box" >
{/* 操作栏:筛选 + 推送按钮 */}
<div style={{
display: 'flex',
+307 -75
View File
@@ -10,6 +10,9 @@ import {
PictureOutlined,
ThunderboltOutlined,
PlayCircleOutlined,
HeartOutlined,
ShareAltOutlined,
StarOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { getmedit ,getHomeCaseHeader,getHomeCaseButton} from '../api';
@@ -43,7 +46,7 @@ const HomePage: React.FC = () => {
const [caseAssets, setCaseAssets] = useState<any[]>([]);
const [previewAsset, setPreviewAsset] = useState<any>(null);
const previewVideoRef = useRef<HTMLVideoElement>(null);
const [activeContentTab, setActiveContentTab] = useState<'works' | 'cases'>('works');
const [activeContentTab, setActiveContentTab] = useState<'works' | 'cases'>('cases');
useEffect(() => {
getHomeCaseHeader().then((res: any) => {
@@ -67,7 +70,7 @@ const HomePage: React.FC = () => {
const fetchAll = async () => {
try {
getmedit(5).then((res) => {
getmedit(10).then((res) => {
for (let item in res) {
res[item].forEach(element => {
// 给每条 element 标记它所属的模块(item 是接口返回的 key)
@@ -479,10 +482,9 @@ const HomePage: React.FC = () => {
onClick={() => navigate(entry.path)}
className="project-card"
style={{
flex: '1',
minWidth: 260,
height: 120,
padding: '16px',
flex: '1 1 280px',
minWidth: 280,
padding: '16px 20px',
borderRadius: 16,
background: '#fff',
border: '1px solid #e2e8f0',
@@ -492,6 +494,7 @@ const HomePage: React.FC = () => {
display: 'flex',
alignItems: 'center',
gap: 14,
minHeight: 96,
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = accent.color;
@@ -529,18 +532,27 @@ const HomePage: React.FC = () => {
>
<span style={{ fontSize: 20, display: 'flex', transition: 'all 0.3s ease' }}>{entry.icon}</span>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<span style={{ fontSize: 15, fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 15, fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1, minWidth: 0 }}>
{entry.title}
</span>
<span style={{
fontSize: 10, color: accent.color,
padding: '2px 7px', borderRadius: 4,
background: accent.light, fontWeight: 600,
flexShrink: 0,
}}>{accent.tag}</span>
</div>
<div style={{ fontSize: 12, color: '#64748b', }}>
<div style={{
fontSize: 12,
color: '#64748b',
lineHeight: 1.5,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}>
{entry.description}
</div>
</div>
@@ -583,12 +595,13 @@ const HomePage: React.FC = () => {
{/* 外层Tab切换:近期作品 / 素材案例 */}
<div style={{ display: 'flex', gap: 8 }}>
{[
{ key: 'works', label: '近期作品' },
{ key: 'cases', label: '素材案例' },
{ key: 'works', label: '近期作品' },
].map((item) => (
<button
key={item.key}
onClick={() => setActiveContentTab(item.key as 'works' | 'cases')}
onClick={() => setActiveContentTab(item.key as 'cases' | 'works')}
style={{
padding: '6px 16px',
borderRadius: 8,
@@ -781,10 +794,10 @@ const HomePage: React.FC = () => {
</div>
{/* 素材案例网格 */}
<div className="stagger-children" style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 16 }}>
<div className="stagger-children" style={{ display: 'flex', flexWrap: 'wrap', gap: 16, justifyContent: 'space-between', overflowX: 'auto', paddingBottom: 8 }}>
{caseAssets.length === 0 ? (
<div style={{
gridColumn: '1 / -1',
flex: 1,
padding: '60px 0',
textAlign: 'center',
color: '#94a3b8',
@@ -802,11 +815,12 @@ const HomePage: React.FC = () => {
borderRadius: 12,
overflow: 'hidden',
cursor: 'pointer',
background: '#fff',
border: '1px solid #e2e8f0',
// background: '#0f172a',
flexShrink: 0,
width: "18%",
}}
>
<div style={{ position: 'relative', aspectRatio: '16/9', }}>
<div style={{ position: 'relative', aspectRatio: '9/16', }}>
{asset.mediaType === 'video' ? (
<>
<video
@@ -821,9 +835,18 @@ const HomePage: React.FC = () => {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0,0,0,0.2)',
}}>
<VideoCameraOutlined style={{ fontSize: 28, color: '#fff' }} />
<div style={{
width: 40,
height: 40,
borderRadius: '50%',
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<PlayCircleOutlined style={{ fontSize: 24, color: '#fff' }} />
</div>
</div>
</>
) : (
@@ -833,22 +856,43 @@ const HomePage: React.FC = () => {
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
)}
{/* {asset.mediaType === 'video' && (
<div style={{
position: 'absolute',
top: 8,
right: 8,
fontSize: 10,
color: '#fff',
background: 'rgba(0,0,0,0.6)',
padding: '2px 6px',
borderRadius: 4,
}}>
AI生成
</div>
)} */}
</div>
<div style={{
padding: '10px 12px',
background: '#f8fafc',
}}>
{/* <div style={{ padding: '8px 10px' }}>
<div style={{
fontSize: 12,
color: '#64748b',
textAlign: 'center',
fontSize: 11,
color: '#94a3b8',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
marginBottom: 4,
}}>
{asset.title || `素材 ${index + 1}`}
</div>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 10,
color: '#94a3b8',
}}>
<HeartOutlined style={{ fontSize: 12 }} />
<span>{asset.likes || Math.floor(Math.random() * 1000)}</span>
</div>
</div> */}
</div>
))}
</div>
@@ -864,60 +908,248 @@ const HomePage: React.FC = () => {
setPreviewAsset(null);
}}
footer={null}
width={760}
width={900}
centered
className="preview-modal"
bodyStyle={{
padding: 0,
background: '#fff',
borderRadius: 16,
overflow: 'hidden',
}}
style={{ borderRadius: 16, overflow: 'hidden' }}
>
{/* 固定比例容器 16:9 */}
<div style={{
width: '100%',
paddingTop: '56.25%',
position: 'relative',
// background: '#000',
}}>
<div style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
{previewAsset?.mediaType === 'video' ? (
<video
ref={previewVideoRef}
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset.url}`}
controls
autoPlay
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset?.url}`}
alt={previewAsset?.title}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
)}
<div style={{ display: 'flex', height: 580 }}>
{/* 左侧:素材预览 */}
<div style={{ flex: 1, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
<div style={{ width: 280, aspectRatio: '9/16', borderRadius: 12, overflow: 'hidden', background: '#0f172a' }}>
{previewAsset?.mediaType === 'video' ? (
<video
ref={previewVideoRef}
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset.url}`}
controls
autoPlay
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
webkit-playsinline="true"
/>
) : (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset?.url}`}
alt={previewAsset?.title}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
)}
{previewAsset?.mediaType === 'video' && (
<div style={{
position: 'absolute',
top: 32,
right: 32,
fontSize: 11,
color: '#fff',
background: 'rgba(0,0,0,0.6)',
padding: '3px 8px',
borderRadius: 4,
}}>
AI生成
</div>
)}
</div>
</div>
{/* 右侧:创意详情 */}
<div style={{ flex: 1, padding: 24, overflowY: 'auto' }}>
<div style={{ fontSize: 16, fontWeight: 600, color: '#fff', marginBottom: 16 }}>
</div>
{/* 热度 */}
{/* <div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#94a3b8' }}>
<span style={{ color: '#f59e0b' }}></span>
{previewAsset?.likes || Math.floor(Math.random() * 5000)}
</div>
<div style={{ fontSize: 12, color: '#94a3b8' }}>
<span style={{ color: '#6366f1' }}></span>
{Math.floor(Math.random() * 1000)}
</div>
</div> */}
{/* 视频提示词 */}
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#000000ff', marginBottom: 8 }}></div>
<div style={{ fontSize: 13, color: '#000000ff', lineHeight: 1.6 }}>
{previewAsset?.prompt || '动态描述:女性抬手整理头发,随后手持口服液用手指向产品讲解;画面切换为双手将口服液中的棕黄色液体缓缓倒入透明玻璃杯;再次切换女性讲解画面,双手做出展示动作指向产品;最后双手在胸前做出托手姿势后摊开手掌微笑描述:女性讲解的...'}
</div>
</div>
{/* 视频参考图 */}
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#94a3b8', marginBottom: 8 }}></div>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ width: 80, height: 80, borderRadius: 8, overflow: 'hidden', cursor: 'pointer' }}>
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset?.url}`}
alt="参考图"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</div>
<div style={{
width: 80,
height: 80,
borderRadius: 8,
border: '1px dashed #475569',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#64748b',
fontSize: 12,
}}>
</div>
</div>
</div>
{/* 口播脚本台词 */}
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#000000ff', marginBottom: 8 }}></div>
<div style={{ fontSize: 13, color: '#000000ff', lineHeight: 1.6 }}>
{previewAsset?.script || '还在为宝宝不爱喝水发愁?试试这款天然果蔬汁!零添加糖分,维生素满满,口感清甜宝宝超爱喝。现在下单还送专属吸管杯,手慢无!点下方链接把健康带回家~'}
</div>
{/* <div style={{
fontSize: 12,
color: '#6366f1',
marginTop: 8,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 4,
}}>
</div> */}
</div>
{/* 视频标签 */}
{/* <div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#94a3b8', marginBottom: 8 }}></div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{previewAsset?.tags?.split?.(',')?.map((tag: string, i: number) => (
<span
key={i}
style={{
padding: '4px 10px',
borderRadius: 16,
background: '#334155',
color: '#94a3b8',
fontSize: 11,
}}
>
{tag.trim()}
</span>
)) || ['互联网电商服务', '美妆', '美妆', '服装配饰', '母婴宠物', '带货主播', '口播'].map((tag, i) => (
<span
key={i}
style={{
padding: '4px 10px',
borderRadius: 16,
background: '#334155',
color: '#94a3b8',
fontSize: 11,
}}
>
{tag}
</span>
))}
</div>
</div> */}
</div>
</div>
{/* 底部标题栏 */}
{previewAsset?.title && (
<div style={{
padding: '14px 20px',
fontSize: 14,
color: '#4b5563',
fontWeight: 500,
borderTop: '1px solid #f1f5f9',
textAlign: 'center',
}}>
{previewAsset.title}
{/* 底部操作栏 */}
<div style={{
padding: '16px 24px',
borderTop: '1px solid #334155',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
}}>
{/* <div style={{ display: 'flex', gap: 16 }}>
<button
onClick={() => message.info('收藏功能开发中')}
style={{
width: 36,
height: 36,
borderRadius: '50%',
border: 'none',
background: '#334155',
color: '#94a3b8',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = '#475569';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = '#334155';
}}
>
<StarOutlined style={{ fontSize: 16 }} />
</button>
<button
onClick={() => message.info('分享功能开发中')}
style={{
width: 36,
height: 36,
borderRadius: '50%',
border: 'none',
background: '#334155',
color: '#000000ff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = '#475569';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = '#334155';
}}
>
<ShareAltOutlined style={{ fontSize: 16 }} />
</button>
</div> */}
<div style={{ display: 'flex', gap: 12 }}>
{previewAsset?.mediaType !== 'video' && (
<Button
// onClick={() => navigate('/generate')}
style={{
padding: '8px 24px',
borderRadius: 8,
background: '#3b82f6',
border: 'none',
color: '#fff',
fontSize: 13,
fontWeight: 500,
}}
>
AI创作
</Button>
)}
<Button
// onClick={() => navigate('/initial')}
style={{
padding: '8px 24px',
borderRadius: 8,
background: '#8b5cf6',
border: 'none',
color: '#fff',
fontSize: 13,
fontWeight: 500,
}}
>
</Button>
</div>
)}
</div>
</Modal>
</div>
);
+58 -19
View File
@@ -10,6 +10,7 @@ import {
Table,
Space,
Pagination,
Popconfirm,
} from 'antd';
import {
PlusOutlined,
@@ -18,7 +19,8 @@ import {
LoadingOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { uploadHotOpeningVideo, uploadHotOpeningImage, generateReplication, getReplicationList, getone, getReplicationDetail } from '../api';
import { uploadHotOpeningVideo, uploadHotOpeningImage, generateReplication, getReplicationList, getone, getReplicationDetail, deleteHotOpeningReplicationTask } from '../api';
const { Header, Content } = Layout;
const { TextArea } = Input;
@@ -1227,7 +1229,7 @@ const GenerateConver: React.FC = () => {
dataIndex: 'targetProjectName',
key: 'targetProjectName',
align: 'center',
width: 200,
width: 100,
render: (text: string) => (
<span style={{ fontSize: 13, color: '#1e293b', fontWeight: 500 }}>
{text || '-'}
@@ -1350,24 +1352,61 @@ const GenerateConver: React.FC = () => {
title: '操作',
key: 'action',
align: 'center',
width: 120,
width: 220,
render: (_, record) => (
<button
onClick={() => navigate(`/initial/${record.id}/initialinfo`)}
style={{
color: '#6366f1',
textDecoration: 'none',
fontSize: 13,
border: 'none',
background: 'rgba(99, 102, 241, 0.08)',
padding: '4px 12px',
borderRadius: 8,
cursor: 'pointer',
transition: 'all 0.2s',
}}
>
</button>
<Space>
<button
onClick={() => navigate(`/initial/${record.id}/initialinfo`)}
style={{
color: '#6366f1',
textDecoration: 'none',
fontSize: 13,
border: 'none',
background: 'rgba(99, 102, 241, 0.08)',
padding: '4px 12px',
borderRadius: 8,
cursor: 'pointer',
transition: 'all 0.2s',
}}
>
</button>
<Popconfirm
title="确认删除这个爆款开头复刻任务吗?"
onConfirm={async () => {
try {
await deleteHotOpeningReplicationTask(record.id);
message.success('删除成功');
fetchList(1, pageSize, false, searchKeyword);
} catch (err) {
message.error('删除失败');
}
}}
>
<button
style={{
color: '#ef4444',
textDecoration: 'none',
fontSize: 13,
border: 'none',
background: 'rgba(239, 68, 68, 0.08)',
padding: '4px 12px',
borderRadius: 8,
cursor: 'pointer',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = 'rgba(239, 68, 68, 0.12)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = 'rgba(239, 68, 68, 0.08)';
}}
>
</button>
</Popconfirm>
</Space>
),
},
]}
+310 -183
View File
@@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Button, Drawer, Input, Modal, Popconfirm, Spin, Table, Tag, Tooltip, Upload, message } from 'antd';
import { ArrowLeftOutlined, PlusOutlined, XOutlined } from '@ant-design/icons';
import { createRemoveLens, getShotReplicationDetail, Removelist, removeCreate, splitCustom, uploadShotReplicateImage } from '../api';
import {uploadShotReplicateImage, createRemoveLens, deleteSegment, getShotReplicationDetail, reanalyzeSegment, Removelist, removeCreate, reanalyzeShotReplication, splitCustom, uploadImage } from '../api';
import VideoTrimPicker from '../components/VideoTrimPicker';
const { TextArea } = Input;
@@ -132,6 +134,51 @@ function RemoveInfo() {
}
}, [creatID]);
const handleReanalyze = useCallback(async () => {
if (!creatID) return;
try {
await reanalyzeShotReplication(creatID);
message.success('重新分析已提交');
fetchTaskDetail();
if (!analysisPollingRef.current) {
analysisPollingRef.current = window.setInterval(async () => {
try {
const res = await getShotReplicationDetail(creatID);
setTaskDetail(res);
if (res.analysisStatus === 'completed' || res.analysisStatus === 'failed') {
if (analysisPollingRef.current) {
clearInterval(analysisPollingRef.current);
analysisPollingRef.current = null;
}
}
} catch { }
}, 3000);
}
} catch (error: any) {
message.error(error?.message || '重新分析失败');
}
}, [creatID, fetchTaskDetail]);
const handleSegmentReanalyze = useCallback(async (segmentId: string) => {
try {
await reanalyzeSegment(segmentId);
message.success('重新分析已提交');
fetchSegments();
} catch (error: any) {
message.error(error?.message || '重新分析失败');
}
}, [fetchSegments]);
const handleDeleteSegment = useCallback(async (segmentId: string) => {
try {
await deleteSegment(segmentId);
message.success('删除成功');
fetchSegments();
} catch (error: any) {
message.error(error?.message || '删除失败');
}
}, [fetchSegments]);
const refreshPageData = useCallback(async () => {
await Promise.all([fetchTaskDetail(), fetchSegments()]);
}, [fetchTaskDetail, fetchSegments]);
@@ -155,7 +202,7 @@ function RemoveInfo() {
if (!taskDetail) return;
if (taskDetail.analysisStatus === 'processing') {
analysisPollingRef.current = window.setInterval(async () => {
try {
const res = await getShotReplicationDetail(creatID);
@@ -231,8 +278,9 @@ function RemoveInfo() {
return;
}
const params = {
target_project_name: productName.trim(),
core_content_point: productSellingPoint.trim(),
material_image_url: productImage,
@@ -247,23 +295,23 @@ function RemoveInfo() {
message.success('视频生成任务创建成功');
handleCloseDrawer();
Removelist(creatID).then((res: any) => {
const targetItem = res.items.find((item: any) => item.id === currentSegment);
message.loading('创建中...', 3);
setTimeout(() => {
const targetItem = res.items.find((item: any) => item.id === currentSegment);
message.loading('创建中...', 3);
setTimeout(() => {
navigate(`/removelens/${targetItem.moduleProjectId}/removefenbu`);
}, 3000);
});
// fetchSegments().then((res: any) => {
// console.log('123123123123',res);
// // const targetItem = res.items.find((item: any) => item.id === currentSegment);
// // console.log(targetItem);
// });
// if (targetItem?.moduleProjectId) {
// message.loading('跳转中...', 1.5);
//
@@ -385,13 +433,13 @@ function RemoveInfo() {
width: 500,
align: 'left' as const,
render: (_: any, record: any) => {
// AI建议的片段直接显示内容,不经过分析状态判断
if (record.source_mode === 'ai_suggestion' || record.sourceMode === 'ai_suggestion') {
return <div style={{ fontSize: 14, color: '#333', lineHeight: 1.6 }}>{record.segmentContent || record.lastError || '-'}</div>;
}
const analysisStatus = record.analysis_status || record.analysisStatus;
const statusMap: Record<string, string> = {
'not_required': '无需单独分析',
@@ -399,7 +447,7 @@ function RemoveInfo() {
'processing': '分析中',
'failed': '分析失败',
};
if (analysisStatus === 'processing') {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
@@ -408,11 +456,11 @@ function RemoveInfo() {
</div>
);
}
if (analysisStatus === 'failed') {
return <div style={{ fontSize: 14, color: '#ef4444', lineHeight: 1.6 }}></div>;
}
const displayText = analysisStatus && statusMap[analysisStatus] ? statusMap[analysisStatus] : (record.segmentContent || record.lastError || '-');
return <div style={{ fontSize: 14, color: '#333', lineHeight: 1.6 }}>{displayText}</div>;
},
@@ -432,100 +480,100 @@ function RemoveInfo() {
width: 120,
align: 'center' as const,
dataIndex: 'moduleProjectStatus',
render: (text: string, record: any) => {
const status = text;
const currentStepCode = record.moduleProjectCurrentStepCode || record.currentStepCode;
const getStatusText = () => {
render: (text: string, record: any) => {
const status = text;
const currentStepCode = record.moduleProjectCurrentStepCode || record.currentStepCode;
const getStatusText = () => {
if (currentStepCode === 'image_prompt_optimize') {
switch (status) {
case 'waiting_user':
return { text: '等待融合图生成', color: '#f59e0b' };
case 'processing':
return { text: '图片提示词生成中', color: '#f59e0b' };
case 'completed':
return { text: '图片提示词生成成功', color: '#10b981' };
case 'failed':
return { text: '图片提示词生成失败', color: '#ef4444' };
default:
return { text: status || '-', color: '#666' };
}
switch (status) {
case 'waiting_user':
return { text: '等待融合图生成', color: '#f59e0b' };
case 'processing':
return { text: '图片提示词生成中', color: '#f59e0b' };
case 'completed':
return { text: '图片提示词生成成功', color: '#10b981' };
case 'failed':
return { text: '图片提示词生成失败', color: '#ef4444' };
default:
return { text: status || '-', color: '#666' };
}
} else if (currentStepCode === 'image_generate') {
switch (status) {
case 'waiting_user':
return { text: '等待生成视频提示词', color: '#f59e0b' };
case 'processing':
return { text: '融合图生成中', color: '#f59e0b' };
case 'completed':
return { text: '融合图生成成功', color: '#10b981' };
case 'failed':
return { text: '融合图生成失败', color: '#ef4444' };
default:
return status || '-';
}
switch (status) {
case 'waiting_user':
return { text: '等待生成视频提示词', color: '#f59e0b' };
case 'processing':
return { text: '融合图生成中', color: '#f59e0b' };
case 'completed':
return { text: '融合图生成成功', color: '#10b981' };
case 'failed':
return { text: '融合图生成失败', color: '#ef4444' };
default:
return status || '-';
}
} else if (currentStepCode === 'video_prompt_optimize') {
switch (status) {
case 'waiting_user':
return { text: '等待最终视频生成', color: '#f59e0b' };
case 'processing':
return { text: '视频提示词生成中', color: '#f59e0b' };
case 'completed':
return { text: '视频提示词生成成功', color: '#10b981' };
case 'failed':
return { text: '视频提示词生成失败', color: '#ef4444' };
default:
return { text: status || '-', color: '#666' };
}
switch (status) {
case 'waiting_user':
return { text: '等待最终视频生成', color: '#f59e0b' };
case 'processing':
return { text: '视频提示词生成中', color: '#f59e0b' };
case 'completed':
return { text: '视频提示词生成成功', color: '#10b981' };
case 'failed':
return { text: '视频提示词生成失败', color: '#ef4444' };
default:
return { text: status || '-', color: '#666' };
}
} else if (currentStepCode === 'video_generate') {
switch (status) {
case 'waiting_user':
return { text: '', color: '#666' };
case 'processing':
return { text: '最终视频生成中', color: '#f59e0b' };
case 'completed':
return { text: '任务完成', color: '#10b981' };
case 'failed':
return { text: '最终视频生成失败', color: '#ef4444' };
default:
return { text: status || '-', color: '#666' };
}
switch (status) {
case 'waiting_user':
return { text: '', color: '#666' };
case 'processing':
return { text: '最终视频生成中', color: '#f59e0b' };
case 'completed':
return { text: '任务完成', color: '#10b981' };
case 'failed':
return { text: '最终视频生成失败', color: '#ef4444' };
default:
return { text: status || '-', color: '#666' };
}
} else if (currentStepCode === 'material_input') {
switch (status) {
case 'waiting_user':
return { text: '等待生成图片提示词', color: '#f59e0b' };
case 'processing':
return { text: '素材处理中', color: '#f59e0b' };
case 'completed':
return { text: '素材上传成功', color: '#10b981' };
case 'failed':
return { text: '素材上传失败', color: '#ef4444' };
default:
return { text: status || '-', color: '#666' };
}
switch (status) {
case 'waiting_user':
return { text: '等待生成图片提示词', color: '#f59e0b' };
case 'processing':
return { text: '素材处理中', color: '#f59e0b' };
case 'completed':
return { text: '素材上传成功', color: '#10b981' };
case 'failed':
return { text: '素材上传失败', color: '#ef4444' };
default:
return { text: status || '-', color: '#666' };
}
} else {
if (!record.moduleProjectId) {
return { text: '待生成任务', color: '#94a3b8' };
}
const statusMap: Record<string, { text: string; color: string }> = {
'pending': { text: '子任务待处理', color: '#f59e0b' },
'waiting_user': { text: '等待用户确认或触发', color: '#f59e0b' },
'processing': { text: '子任务处理中', color: '#f59e0b' },
'completed': { text: '子任务完成', color: '#10b981' },
'failed': { text: '子任务失败', color: '#ef4444' },
'cancelled': { text: '子任务取消', color: '#94a3b8' },
};
const result = statusMap[status] || { text: status || '-', color: '#666' };
return result;
if (!record.moduleProjectId) {
return { text: '待生成任务', color: '#94a3b8' };
}
const statusMap: Record<string, { text: string; color: string }> = {
'pending': { text: '子任务待处理', color: '#f59e0b' },
'waiting_user': { text: '等待用户确认或触发', color: '#f59e0b' },
'processing': { text: '子任务处理中', color: '#f59e0b' },
'completed': { text: '子任务完成', color: '#10b981' },
'failed': { text: '子任务失败', color: '#ef4444' },
'cancelled': { text: '子任务取消', color: '#94a3b8' },
};
const result = statusMap[status] || { text: status || '-', color: '#666' };
return result;
}
};
const result = getStatusText() as { text: string; color: string };
if (typeof result === 'string') {
};
const result = getStatusText() as { text: string; color: string };
if (typeof result === 'string') {
return <span style={{ fontSize: 12, color: '#666', fontWeight: 500 }}>{result}</span>;
}
return <span style={{ fontSize: 12, color: result.color, fontWeight: 500 }}>{result.text}</span>;
},
}
return <span style={{ fontSize: 12, color: result.color, fontWeight: 500 }}>{result.text}</span>;
},
// render: (_: any, record: any) => {
// const statusMap: Record<string, { text: string; color: string }> = {
@@ -564,6 +612,28 @@ function RemoveInfo() {
{canCreateReplication(record) ? '视频生成' : '待切割完成'}
</Button>
)}
{record.analysisStatus === 'failed' && (
<Button
type="text"
onClick={() => handleSegmentReanalyze(String(record.id))}
style={{ color: '#ef4444', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
>
</Button>
)}
<Popconfirm
title="确定删除此片段?"
onConfirm={() => handleDeleteSegment(String(record.id))}
okText="确定"
cancelText="取消"
>
<Button
type="text"
style={{ color: '#ef4444', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
>
</Button>
</Popconfirm>
</div>
),
},
@@ -618,35 +688,88 @@ function RemoveInfo() {
</div>
{taskDetail ? (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', overflow: 'hidden', padding: '0 32px 32px' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', overflow: 'hidden', }}>
{/* 视频总结卡片 */}
<div
style={{
flex: 1,
background: 'linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(255,255,255,0.6) 100%)',
<div
style={{
flex: 1,
background: 'linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(255,255,255,0.6) 100%)',
backdropFilter: 'blur(20px)',
borderRadius: 20,
display: 'flex',
alignItems: 'center',
borderRadius: 20,
display: 'flex',
alignItems: 'center',
gap: 30,
padding: 24,
border: '1px solid rgba(99, 102, 241, 0.1)',
boxShadow: '0 8px 32px rgba(99, 102, 241, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.8)',
// boxShadow: '0 8px 32px rgba(99, 102, 241, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.8)',
position: 'relative',
overflow: 'hidden',
}}
>
{/* 卡片装饰 */}
<div style={{ position: 'absolute', top: -50, right: -50, width: 200, height: 200, background: 'radial-gradient(circle, rgba(99,102,241,0.05) 0%, transparent 70%)', borderRadius: '50%' }} />
<div style={{ position: 'relative', width: 280, height: 160, borderRadius: 12, overflow: 'hidden', flexShrink: 0, boxShadow: '0 4px 16px rgba(0,0,0,0.1)' }}>
<video
controls
src={videoUrl}
style={{ width: '100%', height: '100%'}}
/>
<div>
<div
style={{ position: 'relative', width: 280, height: 160, borderRadius: 12, overflow: 'hidden', flexShrink: 0, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', cursor: 'pointer' }}
onClick={() => {
setPreviewVideoUrl(videoUrl);
setPreviewModalVisible(true);
}}
onMouseEnter={(e) => {
const overlay = e.currentTarget.querySelector('div:last-child') as HTMLElement;
if (overlay) overlay.style.opacity = '1';
}}
onMouseLeave={(e) => {
const overlay = e.currentTarget.querySelector('div:last-child') as HTMLElement;
if (overlay) overlay.style.opacity = '0';
}}
>
<video
src={videoUrl}
style={{ width: '100%', height: '100%', objectFit: 'cover', }}
/>
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0, transition: 'opacity 0.2s' }}>
<div style={{ width: 40, height: 40, borderRadius: '50%', background: 'rgba(255,255,255,0.9)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#6366f1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="5 3 19 12 5 21 5 3"></polygon>
</svg>
</div>
</div>
</div>
{taskDetail.analysisStatus === 'failed' && (
<div style={{ marginTop: 12, textAlign: 'center' }}>
<Button
onClick={handleReanalyze}
style={{
width: "100%",
padding: '6px 16px',
borderRadius: 8,
fontSize: 12,
border: '1px solid #ef4444',
color: '#ef4444',
background: 'rgba(239, 68, 68, 0.05)',
cursor: taskDetail.analysisStatus === 'processing' ? 'not-allowed' : 'pointer',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
if (taskDetail.analysisStatus !== 'processing') {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.05)';
}}
>
{taskDetail.analysisStatus === 'processing' ? '分析中...' : '重新分析'}
</Button>
</div>
)}
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', position: 'relative', zIndex: 1 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -674,6 +797,8 @@ function RemoveInfo() {
</Tag>
))}
</div>
) : taskDetail.analysisStatus === 'failed' ? (
<span style={{ color: '#ef4444', fontSize: 14 }}></span>
) : null}
</div>
<div style={{ display: 'flex' }}>
@@ -684,7 +809,9 @@ function RemoveInfo() {
<span style={{ fontSize: 14, color: '#f59e0b' }}></span>
</div>
) : taskDetail.analysisStatus === 'completed' ? (
<span style={{ color: '#475569', lineHeight: 1.6 , height: 100,overflowY: 'auto'}}>{taskDetail.originalVideoContent}</span>
<span style={{ color: '#475569', lineHeight: 1.6, height: 100, overflowY: 'auto' }}>{taskDetail.originalVideoContent}</span>
) : taskDetail.analysisStatus === 'failed' ? (
<span style={{ color: '#ef4444', fontSize: 14 }}></span>
) : null}
</div>
</div>
@@ -753,55 +880,55 @@ function RemoveInfo() {
>
<span style={{ position: 'relative', zIndex: 1 }}>{autoSplitButtonText}</span>
{/* 按钮光效 */}
<div style={{
position: 'absolute',
top: 0,
left: '-100%',
width: '100%',
height: '100%',
<div style={{
position: 'absolute',
top: 0,
left: '-100%',
width: '100%',
height: '100%',
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent)',
transition: 'left 0.5s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.left = '100%';
}} />
}}
onMouseEnter={(e) => {
e.currentTarget.style.left = '100%';
}} />
{taskDetail?.aiSuggestions && taskDetail.aiSuggestions.length > 0 && (
<Tooltip
title={
<div>
<div style={{ fontWeight: 600, marginBottom: 8, color: '#6366f1' }}>AI拆分方案</div>
{(() => {
const suggestions = taskDetail?.ai_suggestions || taskDetail?.aiSuggestions || [];
return suggestions.map((item: any) => (
<div key={item.index} style={{ marginBottom: 8, fontSize: 13, lineHeight: 1.5 }}>
<span style={{ fontWeight: 500, color: '#6366f1' }}>{item.index}.</span>
<span style={{ marginLeft: 4, color: '#000000ff' }}>{item.timeNode}</span>
<br />
<span style={{ color: '#666' }}>{item.content}</span>
</div>
));
})()}
</div>
}
placement="top"
trigger="hover"
overlayInnerStyle={{ backgroundColor: '#fff', height: 300, overflowY: 'auto', border: '1px solid rgba(99, 102, 241, 0.1)', borderRadius: 12, boxShadow: '0 8px 24px rgba(99, 102, 241, 0.12)' }}
>
<span style={{ marginLeft: 8, fontSize: 13, opacity: 0.9 }}>(AI )</span>
</Tooltip>
<Tooltip
title={
<div>
<div style={{ fontWeight: 600, marginBottom: 8, color: '#6366f1' }}>AI拆分方案</div>
{(() => {
const suggestions = taskDetail?.ai_suggestions || taskDetail?.aiSuggestions || [];
return suggestions.map((item: any) => (
<div key={item.index} style={{ marginBottom: 8, fontSize: 13, lineHeight: 1.5 }}>
<span style={{ fontWeight: 500, color: '#6366f1' }}>{item.index}.</span>
<span style={{ marginLeft: 4, color: '#000000ff' }}>{item.timeNode}</span>
<br />
<span style={{ color: '#666' }}>{item.content}</span>
</div>
));
})()}
</div>
}
placement="top"
trigger="hover"
overlayInnerStyle={{ backgroundColor: '#fff', height: 300, overflowY: 'auto', border: '1px solid rgba(99, 102, 241, 0.1)', borderRadius: 12, boxShadow: '0 8px 24px rgba(99, 102, 241, 0.12)' }}
>
<span style={{ marginLeft: 8, fontSize: 13, opacity: 0.9 }}>(AI )</span>
</Tooltip>
)}
</Button>
</Popconfirm>
</div>
<div
style={{
flex: 2,
background: 'linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 100%)',
<div
style={{
flex: 2,
background: 'linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 100%)',
backdropFilter: 'blur(20px)',
borderRadius: 20,
overflow: 'hidden',
display: 'flex',
borderRadius: 20,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
border: '1px solid rgba(99, 102, 241, 0.1)',
boxShadow: '0 8px 32px rgba(99, 102, 241, 0.08)',
@@ -822,43 +949,43 @@ function RemoveInfo() {
components={{
body: {
row: ({ className, style, ...rest }) => (
<tr
{...rest}
className={className}
style={{
...style,
<tr
{...rest}
className={className}
style={{
...style,
transition: 'all 0.2s ease',
borderBottom: '1px solid rgba(99, 102, 241, 0.05)',
height: 80,
overflow: 'auto',
}}
}}
/>
),
cell: ({ className, style, ...rest }) => (
<td
{...rest}
className={className}
style={{
...style,
<td
{...rest}
className={className}
style={{
...style,
padding: '16px 24px',
}}
}}
/>
),
},
header: {
cell: ({ className, style, ...rest }) => (
<th
{...rest}
className={className}
style={{
...style,
<th
{...rest}
className={className}
style={{
...style,
background: 'rgba(255, 255, 255, 1)',
color: '#64748b',
fontWeight: 500,
fontSize: 13,
padding: '16px 24px',
borderBottom: 'none',
}}
}}
/>
),
},
@@ -1016,17 +1143,17 @@ function RemoveInfo() {
centered
styles={{
body: { padding: 24, },
header: { borderBottom: '1px solid #e0e0e0', padding: '20px 24px' },
header: { borderBottom: '1px solid #e0e0e0', padding: '20px 24px' },
}}
>
<div style={{ borderRadius: 12, overflow: 'hidden', }}>
<div style={{ borderRadius: 12, overflow: 'hidden', }}>
<video
ref={previewVideoRef}
controls
src={previewVideoUrl || ''}
style={{ width: '100%', maxHeight: 420, objectFit: 'contain', backgroundColor: '#000' }}
playsInline
webkit-playsinline
webkit-playsinline="true"
autoPlay
/>
</div>
+80 -21
View File
@@ -1,10 +1,29 @@
import { useState, useRef, useCallback } from 'react';
import { Button, Modal, Input, Table, Upload, Popconfirm, message } from 'antd';
import { Button, Modal, Input, Table, Upload, Popconfirm, Tag, Space, message } from 'antd';
import { FileTextOutlined, CloudUploadOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { uploadShotReplicateVideo, createShotReplication, getShotReplicationList } from '../api';
import { uploadShotReplicateVideo, createShotReplication, getShotReplicationList, deleteShotReplicationProject } from '../api';
import bg1 from '../assets/bg1.png';
const statusConfig: Record<string, { label: string; color: string }> = {
pending_analysis: { label: '等待分析', color: 'default' },
analyzing: { label: '分析中', color: 'processing' },
analysis_completed: { label: '分析完成', color: 'blue' },
analysis_failed: { label: '分析失败', color: 'red' },
splitting: { label: '拆镜中', color: 'processing' },
split_completed: { label: '拆镜完成', color: 'green' },
partial_failed: { label: '部分失败', color: 'orange' },
failed: { label: '失败', color: 'red' },
deleted: { label: '已软删', color: 'default' },
};
const renderStatus = (status: string) => {
const config = statusConfig[status] || { label: status, color: 'default' };
return <Tag color={config.color}>{config.label}</Tag>;
};
export default function VideoFrameExtractor() {
const navigate = useNavigate();
@@ -546,6 +565,12 @@ export default function VideoFrameExtractor() {
<span style={{ fontSize: 14, color: '#1e293b', fontWeight: 500 }}>{text}</span>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => renderStatus(status),
},
{
title: '创建时间',
dataIndex: 'createdAt',
@@ -567,25 +592,59 @@ export default function VideoFrameExtractor() {
key: 'action',
align: 'center',
render: (record) => (
<Button
type="text"
onClick={() => navigate(`/removelens/${record.id}/removeinfo`)}
style={{
color: '#6366f1',
fontSize: 13,
padding: '4px 12px',
borderRadius: 6,
background: 'rgba(99, 102, 241, 0.1)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.1)';
}}
>
</Button>
<Space>
<Button
type="text"
onClick={() => navigate(`/removelens/${record.id}/removeinfo`)}
style={{
color: '#6366f1',
fontSize: 13,
padding: '4px 12px',
borderRadius: 6,
background: 'rgba(99, 102, 241, 0.1)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.1)';
}}
>
</Button>
<Popconfirm
title="确认删除这个拆镜项目吗?"
onConfirm={async () => {
try {
await deleteShotReplicationProject(record.id);
message.success('删除成功');
fetchList(currentPage, pageSize, searchKeyword);
} catch (err) {
message.error('删除失败');
}
}}
>
<Button
type="text"
danger
style={{
color: '#ef4444',
fontSize: 13,
padding: '4px 12px',
borderRadius: 6,
background: 'rgba(239, 68, 68, 0.1)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
}}
>
</Button>
</Popconfirm>
</Space>
),
},
]}