Merge branch 'main' of gitee.com:wg123/video-gen
This commit is contained in:
Vendored
+89
-89
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</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">
|
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
|
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
|
||||||
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
|
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
|
||||||
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
|
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
|
||||||
|
AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -251,39 +252,28 @@ export async function deleteUserResourceCapacity(userId: string): Promise<AdminU
|
|||||||
return api.delete(`/admin/users/${userId}/resource-capacity`);
|
return api.delete(`/admin/users/${userId}/resource-capacity`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadPdf(file: File, configKey: string): Promise<{ url: string }> {
|
export async function uploadAdminFile(
|
||||||
const formData = new FormData();
|
file: File,
|
||||||
formData.append('file', file);
|
options: { scene: AdminUploadScene; resourceType: AdminUploadResourceType; durationSeconds?: number | null },
|
||||||
formData.append('config_key', configKey);
|
): Promise<AdminUploadFileResult> {
|
||||||
const token = localStorage.getItem('auth_token');
|
const form = new FormData();
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
form.append('file', file);
|
||||||
const res = await fetch(`${baseUrl}/api/admin/upload-pdf`, {
|
form.append('scene', options.scene);
|
||||||
method: 'POST',
|
form.append('resource_type', options.resourceType);
|
||||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
if (options.durationSeconds !== undefined && options.durationSeconds !== null) {
|
||||||
body: formData,
|
form.append('duration_seconds', String(options.durationSeconds));
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(text || '上传失败');
|
|
||||||
}
|
}
|
||||||
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 }> {
|
export async function uploadLogo(file: File): Promise<{ url: string }> {
|
||||||
const formData = new FormData();
|
const res = await uploadAdminFile(file, { scene: 'system_logo', resourceType: 'image' });
|
||||||
formData.append('file', file);
|
return { url: res.url };
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMaybe(params: URLSearchParams, key: string, value: unknown): void {
|
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 }> {
|
export async function uploadImage(file: File): Promise<{ url: string; filename: string }> {
|
||||||
const form = new FormData();
|
const res = await uploadAdminFile(file, { scene: 'open_type_thumb', resourceType: 'image' });
|
||||||
form.append('file', file);
|
return { url: res.url, filename: res.originalFileName || res.fileName };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自定义表头字段
|
// 自定义表头字段
|
||||||
@@ -900,6 +881,8 @@ export async function uploadHomeMaterialAsset(payload: HomeMaterialUploadAssetPa
|
|||||||
form.append('file', payload.file);
|
form.append('file', payload.file);
|
||||||
form.append('media_type', payload.mediaType);
|
form.append('media_type', payload.mediaType);
|
||||||
if (payload.title && payload.title.trim()) form.append('title', payload.title.trim());
|
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);
|
form.append('watermark_type', payload.watermarkType);
|
||||||
|
|
||||||
if (payload.watermarkType === 'image') {
|
if (payload.watermarkType === 'image') {
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ interface CreditRatio {
|
|||||||
inputVideoRatio: number;
|
inputVideoRatio: number;
|
||||||
inputVideoBaseCredits: number;
|
inputVideoBaseCredits: number;
|
||||||
inputVideoPerSecondCredits: number;
|
inputVideoPerSecondCredits: number;
|
||||||
|
inputImageRatio: number;
|
||||||
|
inputImageBaseCredits: number;
|
||||||
|
inputImagePerImageCredits: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreditRatioFormValues {
|
interface CreditRatioFormValues {
|
||||||
@@ -37,6 +40,9 @@ interface CreditRatioFormValues {
|
|||||||
inputVideoRatio?: number;
|
inputVideoRatio?: number;
|
||||||
inputVideoBaseCredits?: number;
|
inputVideoBaseCredits?: number;
|
||||||
inputVideoPerSecondCredits?: number;
|
inputVideoPerSecondCredits?: number;
|
||||||
|
inputImageRatio?: number;
|
||||||
|
inputImageBaseCredits?: number;
|
||||||
|
inputImagePerImageCredits?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_IMAGE_SIZES = ['2K', '4K'];
|
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_base_credits = values.inputVideoBaseCredits ?? 0;
|
||||||
payload.input_video_per_second_credits = values.inputVideoPerSecondCredits ?? 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) {
|
if (modal.ratio) {
|
||||||
await saveCreditRatio({ id: modal.ratio.id, ...payload });
|
await saveCreditRatio({ id: modal.ratio.id, ...payload });
|
||||||
message.success('已更新');
|
message.success('已更新');
|
||||||
@@ -188,6 +197,9 @@ const AdminCreditRatios: React.FC = () => {
|
|||||||
inputVideoRatio: ratio.inputVideoRatio,
|
inputVideoRatio: ratio.inputVideoRatio,
|
||||||
inputVideoBaseCredits: ratio.inputVideoBaseCredits,
|
inputVideoBaseCredits: ratio.inputVideoBaseCredits,
|
||||||
inputVideoPerSecondCredits: ratio.inputVideoPerSecondCredits,
|
inputVideoPerSecondCredits: ratio.inputVideoPerSecondCredits,
|
||||||
|
inputImageRatio: ratio.inputImageRatio,
|
||||||
|
inputImageBaseCredits: ratio.inputImageBaseCredits,
|
||||||
|
inputImagePerImageCredits: ratio.inputImagePerImageCredits,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
@@ -199,6 +211,9 @@ const AdminCreditRatios: React.FC = () => {
|
|||||||
inputVideoRatio: 1.0,
|
inputVideoRatio: 1.0,
|
||||||
inputVideoBaseCredits: 0,
|
inputVideoBaseCredits: 0,
|
||||||
inputVideoPerSecondCredits: 0.5,
|
inputVideoPerSecondCredits: 0.5,
|
||||||
|
inputImageRatio: 1.0,
|
||||||
|
inputImageBaseCredits: 0,
|
||||||
|
inputImagePerImageCredits: 0.5,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -238,7 +253,10 @@ const AdminCreditRatios: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '视频倍率', dataIndex: 'inputVideoRatio', width: 100,
|
title: '传入视频',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: '倍率', dataIndex: 'inputVideoRatio', width: 90,
|
||||||
render: (v: number, r: CreditRatio) => (
|
render: (v: number, r: CreditRatio) => (
|
||||||
<Typography.Text>{r.genType === 'image' ? '-' : (
|
<Typography.Text>{r.genType === 'image' ? '-' : (
|
||||||
<span style={{ color: v >= 2 ? '#ef4444' : v >= 1.5 ? '#f59e0b' : '#10b981' }}>x{v}</span>
|
<span style={{ color: v >= 2 ? '#ef4444' : v >= 1.5 ? '#f59e0b' : '#10b981' }}>x{v}</span>
|
||||||
@@ -246,23 +264,49 @@ const AdminCreditRatios: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '视频基础积分', dataIndex: 'inputVideoBaseCredits', width: 110,
|
title: '基础积分', dataIndex: 'inputVideoBaseCredits', width: 100,
|
||||||
render: (v: number, r: CreditRatio) => (
|
render: (v: number, r: CreditRatio) => (
|
||||||
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分`}</Typography.Text>
|
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分`}</Typography.Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '视频每秒积分', dataIndex: 'inputVideoPerSecondCredits', width: 110,
|
title: '每秒积分', dataIndex: 'inputVideoPerSecondCredits', width: 100,
|
||||||
render: (v: number, r: CreditRatio) => (
|
render: (v: number, r: CreditRatio) => (
|
||||||
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分/秒`}</Typography.Text>
|
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分/秒`}</Typography.Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '示例计算(视频15秒,上传视频15秒)', key: 'example', width: 140,
|
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: '示例计算', key: 'example', width: 160,
|
||||||
render: (_: any, r: CreditRatio) => {
|
render: (_: any, r: CreditRatio) => {
|
||||||
let total: number;
|
let total: number;
|
||||||
if (r.genType === 'image') {
|
if (r.genType === 'image') {
|
||||||
total = Math.round(r.baseCredits * r.ratio);
|
total = Math.round(r.baseCredits * r.ratio);
|
||||||
|
if (r.inputImageRatio && r.inputImagePerImageCredits) {
|
||||||
|
total += Math.round(
|
||||||
|
(r.inputImageBaseCredits + r.inputImagePerImageCredits * 3) * r.inputImageRatio
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
total = Math.round((r.baseCredits + r.perSecondCredits * 15) * r.ratio);
|
total = Math.round((r.baseCredits + r.perSecondCredits * 15) * r.ratio);
|
||||||
if (r.inputVideoRatio && r.inputVideoPerSecondCredits) {
|
if (r.inputVideoRatio && r.inputVideoPerSecondCredits) {
|
||||||
@@ -270,6 +314,11 @@ const AdminCreditRatios: React.FC = () => {
|
|||||||
(r.inputVideoBaseCredits + r.inputVideoPerSecondCredits * 15) * r.inputVideoRatio
|
(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>;
|
return <Typography.Text strong style={{ color: '#6366f1' }}>{total} 积分</Typography.Text>;
|
||||||
},
|
},
|
||||||
@@ -346,7 +395,7 @@ const AdminCreditRatios: React.FC = () => {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
scroll={{ x: 1180 }}
|
scroll={{ x: 1500 }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -421,6 +470,29 @@ const AdminCreditRatios: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,25 +41,35 @@ function parseSizes(val: unknown): Record<string, Record<string, string>> {
|
|||||||
|
|
||||||
// Default size options with pixel mappings
|
// Default size options with pixel mappings
|
||||||
const SIZE_OPTIONS: Record<string, Record<string, string>> = {
|
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": {
|
"2K": {
|
||||||
"1:1": "2048×2048",
|
"1:1": "2048x2048",
|
||||||
"4:3": "2304×1728",
|
"4:3": "2304x1728",
|
||||||
"3:4": "1728×2304",
|
"3:4": "1728x2304",
|
||||||
"16:9": "2560×1440",
|
"16:9": "2848x1600",
|
||||||
"9:16": "1600×2848",
|
"9:16": "1600x2848",
|
||||||
"3:2": "2496×1664",
|
"3:2": "2496x1664",
|
||||||
"2:3": "1664×2496",
|
"2:3": "1664x2496",
|
||||||
"21:9": "3024×1296",
|
"21:9": "3136x1344",
|
||||||
},
|
},
|
||||||
"4K": {
|
"4K": {
|
||||||
"1:1": "4096×4096",
|
"1:1": "4096x4096",
|
||||||
"4:3": "4608×3456",
|
"4:3": "4704x3520",
|
||||||
"3:4": "3520×4704",
|
"3:4": "3520x4704",
|
||||||
"16:9": "5404×3040",
|
"16:9": "5504x3040",
|
||||||
"9:16": "3040×5504",
|
"9:16": "3040x5504",
|
||||||
"3:2": "4992×3328",
|
"3:2": "4992x3328",
|
||||||
"2:3": "3328×4992",
|
"2:3": "3328x4992",
|
||||||
"21:9": "6197×2656",
|
"21:9": "6197x2656",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,7 +104,7 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
// Build supportedSizes from form values
|
// Build supportedSizes from form values
|
||||||
const sizes: Record<string, Record<string, string>> = {};
|
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}`] || [];
|
const selected: string[] = values[`size_${tier}`] || [];
|
||||||
if (selected.length > 0) {
|
if (selected.length > 0) {
|
||||||
sizes[tier] = {};
|
sizes[tier] = {};
|
||||||
@@ -147,7 +157,7 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
setModal({ open: true, engine: engine || null });
|
setModal({ open: true, engine: engine || null });
|
||||||
if (engine) {
|
if (engine) {
|
||||||
const sizeFields: Record<string, string[]> = {};
|
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] || {});
|
sizeFields[`size_${tier}`] = Object.keys(engine.supportedSizes?.[tier] || {});
|
||||||
}
|
}
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
@@ -161,6 +171,7 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
supportedModels: ['doubao-seedream-5-0-260128'],
|
supportedModels: ['doubao-seedream-5-0-260128'],
|
||||||
defaultSize: '2K',
|
defaultSize: '2K',
|
||||||
maxImageCount: 0,
|
maxImageCount: 0,
|
||||||
|
size_1K: ALL_RATIOS,
|
||||||
size_2K: ALL_RATIOS,
|
size_2K: ALL_RATIOS,
|
||||||
size_4K: ALL_RATIOS,
|
size_4K: ALL_RATIOS,
|
||||||
});
|
});
|
||||||
@@ -187,6 +198,16 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
</div>
|
</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,
|
title: '2K 支持比例', key: 'sizes_2k', width: 260,
|
||||||
render: (_: any, r: ImageEngine) => {
|
render: (_: any, r: ImageEngine) => {
|
||||||
@@ -294,7 +315,7 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{["2K", "4K"].map(tier => (
|
{["1K", "2K", "4K"].map(tier => (
|
||||||
<div key={tier} style={{
|
<div key={tier} style={{
|
||||||
background: '#fafbfc', borderRadius: 10, padding: '12px 16px',
|
background: '#fafbfc', borderRadius: 10, padding: '12px 16px',
|
||||||
marginBottom: 12, border: '1px solid #f0f0f5',
|
marginBottom: 12, border: '1px solid #f0f0f5',
|
||||||
@@ -318,6 +339,7 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
|
|
||||||
<Form.Item name="defaultSize" label="默认尺寸档位">
|
<Form.Item name="defaultSize" label="默认尺寸档位">
|
||||||
<Select size="large" options={[
|
<Select size="large" options={[
|
||||||
|
{ value: '1K', label: '1K' },
|
||||||
{ value: '2K', label: '2K' },
|
{ value: '2K', label: '2K' },
|
||||||
{ value: '4K', label: '4K' },
|
{ value: '4K', label: '4K' },
|
||||||
]} />
|
]} />
|
||||||
|
|||||||
@@ -105,7 +105,11 @@ const AdminSettings: React.FC = () => {
|
|||||||
const res = await uploadLogo(file);
|
const res = await uploadLogo(file);
|
||||||
setConfigs(prev => prev.map(c => c.key === 'site_logo' ? { ...c, value: res.url } : c));
|
setConfigs(prev => prev.map(c => c.key === 'site_logo' ? { ...c, value: res.url } : c));
|
||||||
form.setFieldsValue({ site_logo: res.url });
|
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 {
|
} catch {
|
||||||
message.error('上传失败');
|
message.error('上传失败');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ const AdminVideoEngines: React.FC = () => {
|
|||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
isActive: true, priority: 0,
|
isActive: true, priority: 0,
|
||||||
maxDuration: 15,
|
maxDuration: 30,
|
||||||
maxImageCount: 2,
|
maxImageCount: 2,
|
||||||
maxVideoCount: 0,
|
maxVideoCount: 0,
|
||||||
maxAudioCount: 0,
|
maxAudioCount: 0,
|
||||||
@@ -123,7 +123,7 @@ const AdminVideoEngines: React.FC = () => {
|
|||||||
supportsUniversalReference: true,
|
supportsUniversalReference: true,
|
||||||
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||||||
supportedResolutions: ['480p', '720p', '1080p'],
|
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 React, { useEffect, useState } from 'react';
|
||||||
import { Button, Dropdown, Image, Modal, Select, Space, Table, Tag, message } from 'antd';
|
import { Button, Dropdown, Image, Input, Modal, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
|
||||||
import { DeleteOutlined, MoreOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, EditOutlined, MoreOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
deleteHomeMaterialAsset,
|
deleteHomeMaterialAsset,
|
||||||
getHomeMaterialAssetStatus,
|
getHomeMaterialAssetStatus,
|
||||||
getHomeMaterialAssets,
|
getHomeMaterialAssets,
|
||||||
regenerateHomeMaterialWatermark,
|
regenerateHomeMaterialWatermark,
|
||||||
|
updateHomeMaterialAsset,
|
||||||
} from '../../api';
|
} from '../../api';
|
||||||
import type {
|
import type {
|
||||||
HomeMaterialAsset,
|
HomeMaterialAsset,
|
||||||
HomeMaterialAssetQueryParams,
|
HomeMaterialAssetQueryParams,
|
||||||
HomeMaterialCategory,
|
HomeMaterialCategory,
|
||||||
|
HomeMaterialMediaReference,
|
||||||
HomeMaterialWatermark,
|
HomeMaterialWatermark,
|
||||||
HomeMaterialWatermarkConfig,
|
HomeMaterialWatermarkConfig,
|
||||||
HomeMaterialTextWatermarkConfig,
|
HomeMaterialTextWatermarkConfig,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
import { apiUrl } from '../../utils/resourceUrl';
|
import { apiUrl } from '../../utils/resourceUrl';
|
||||||
import WatermarkEditor from './WatermarkEditor';
|
import WatermarkEditor from './WatermarkEditor';
|
||||||
|
import MediaReferencesEditor from './MediaReferencesEditor';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
categories: HomeMaterialCategory[];
|
categories: HomeMaterialCategory[];
|
||||||
@@ -57,6 +60,12 @@ const defaultConfig: HomeMaterialWatermarkConfig = {
|
|||||||
textWatermark: defaultTextWatermark,
|
textWatermark: defaultTextWatermark,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mediaTypeText: Record<string, string> = {
|
||||||
|
image: '图片',
|
||||||
|
video: '视频',
|
||||||
|
audio: '音频',
|
||||||
|
};
|
||||||
|
|
||||||
function normalizeTextConfig(raw: any): HomeMaterialTextWatermarkConfig {
|
function normalizeTextConfig(raw: any): HomeMaterialTextWatermarkConfig {
|
||||||
return {
|
return {
|
||||||
text: raw?.text || defaultTextWatermark.text,
|
text: raw?.text || defaultTextWatermark.text,
|
||||||
@@ -94,6 +103,43 @@ function isValidConfig(config: HomeMaterialWatermarkConfig): boolean {
|
|||||||
return !!config.watermarkId;
|
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 HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloadKey }) => {
|
||||||
const [items, setItems] = useState<HomeMaterialAsset[]>([]);
|
const [items, setItems] = useState<HomeMaterialAsset[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
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 [regen, setRegen] = useState<HomeMaterialAsset | null>(null);
|
||||||
const [regenConfig, setRegenConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
|
const [regenConfig, setRegenConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
|
||||||
const [regenSubmitting, setRegenSubmitting] = useState(false);
|
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 () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -133,6 +183,12 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
|||||||
setRegenConfig(normalizeConfig(row.watermarkConfig, row.watermarkId));
|
setRegenConfig(normalizeConfig(row.watermarkConfig, row.watermarkId));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openGenerationConfig = (row: HomeMaterialAsset) => {
|
||||||
|
setEditingConfig(row);
|
||||||
|
setEditPrompt(row.generationPrompt || '');
|
||||||
|
setEditRefs(row.mediaReferences || []);
|
||||||
|
};
|
||||||
|
|
||||||
const getPreviewImageUrl = (row: HomeMaterialAsset) => {
|
const getPreviewImageUrl = (row: HomeMaterialAsset) => {
|
||||||
if (row.mediaType === 'image') return apiUrl(row.watermarkedUrl || row.originalUrl);
|
if (row.mediaType === 'image') return apiUrl(row.watermarkedUrl || row.originalUrl);
|
||||||
return apiUrl(row.coverUrl || '');
|
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 () => {
|
const submitRegenerate = async () => {
|
||||||
if (!regen || regenSubmitting) return;
|
if (!regen || regenSubmitting) return;
|
||||||
if (!isValidConfig(regenConfig)) {
|
if (!isValidConfig(regenConfig)) {
|
||||||
@@ -229,7 +305,7 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
dataSource={items}
|
dataSource={items}
|
||||||
pagination={{ current: filters.page, pageSize: filters.pageSize, total, onChange: (page, pageSize) => setFilters(f => ({ ...f, page, pageSize })) }}
|
pagination={{ current: filters.page, pageSize: filters.pageSize, total, onChange: (page, pageSize) => setFilters(f => ({ ...f, page, pageSize })) }}
|
||||||
scroll={{ x: 1180 }}
|
scroll={{ x: 1380 }}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: '预览',
|
title: '预览',
|
||||||
@@ -256,6 +332,18 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
|||||||
{ title: '行业', dataIndex: 'categoryName' },
|
{ title: '行业', dataIndex: 'categoryName' },
|
||||||
{ title: '类型', dataIndex: 'mediaType', render: (v: string) => v === 'image' ? '图片' : '视频' },
|
{ 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: '状态', 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: '水印',
|
title: '水印',
|
||||||
render: (_: unknown, row: HomeMaterialAsset) => {
|
render: (_: unknown, row: HomeMaterialAsset) => {
|
||||||
@@ -267,7 +355,7 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
|||||||
{ title: '错误', dataIndex: 'errorMessage', ellipsis: true },
|
{ title: '错误', dataIndex: 'errorMessage', ellipsis: true },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 150,
|
width: 170,
|
||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
render: (_: unknown, row: HomeMaterialAsset) => (
|
render: (_: unknown, row: HomeMaterialAsset) => (
|
||||||
<Space size={8}>
|
<Space size={8}>
|
||||||
@@ -276,10 +364,12 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
|||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
menu={{
|
menu={{
|
||||||
items: [
|
items: [
|
||||||
|
{ key: 'generation_config', icon: <EditOutlined />, label: '编辑生成配置' },
|
||||||
{ key: 'regenerate', icon: <ReloadOutlined />, label: '重新生成水印' },
|
{ key: 'regenerate', icon: <ReloadOutlined />, label: '重新生成水印' },
|
||||||
{ key: 'delete', icon: <DeleteOutlined />, label: '删除素材', danger: true },
|
{ key: 'delete', icon: <DeleteOutlined />, label: '删除素材', danger: true },
|
||||||
],
|
],
|
||||||
onClick: ({ key }) => {
|
onClick: ({ key }) => {
|
||||||
|
if (key === 'generation_config') openGenerationConfig(row);
|
||||||
if (key === 'regenerate') openRegenerate(row);
|
if (key === 'regenerate') openRegenerate(row);
|
||||||
if (key === 'delete') confirmDelete(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 && (
|
{preview && (
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||||
<div><b>原素材:</b>{preview.originalUrl ? apiUrl(preview.originalUrl) : '-'}</div>
|
<div><b>原素材:</b>{preview.originalUrl ? apiUrl(preview.originalUrl) : '-'}</div>
|
||||||
<div><b>水印素材:</b>{preview.watermarkedUrl ? apiUrl(preview.watermarkedUrl) : '-'}</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' ? (
|
{preview.mediaType === 'image' ? (
|
||||||
<Image src={apiUrl(preview.watermarkedUrl || preview.originalUrl)} />
|
<Image src={apiUrl(preview.watermarkedUrl || preview.originalUrl)} />
|
||||||
) : (
|
) : (
|
||||||
@@ -305,6 +405,38 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
|||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</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
|
<Modal
|
||||||
title="重新生成水印"
|
title="重新生成水印"
|
||||||
open={!!regen}
|
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 { Form, Input, InputNumber, Modal, Select, Switch, Upload, Button, message } from 'antd';
|
||||||
import { UploadOutlined } from '@ant-design/icons';
|
import { UploadOutlined } from '@ant-design/icons';
|
||||||
import { uploadHomeMaterialAsset } from '../../api';
|
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 WatermarkEditor from './WatermarkEditor';
|
||||||
|
import MediaReferencesEditor from './MediaReferencesEditor';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -50,6 +51,7 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
|
|||||||
const [config, setConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
|
const [config, setConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [mediaObjectUrl, setMediaObjectUrl] = useState<string>('');
|
const [mediaObjectUrl, setMediaObjectUrl] = useState<string>('');
|
||||||
|
const [mediaReferences, setMediaReferences] = useState<HomeMaterialMediaReference[]>([]);
|
||||||
|
|
||||||
const resetState = () => {
|
const resetState = () => {
|
||||||
setFile(null);
|
setFile(null);
|
||||||
@@ -57,6 +59,7 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
|
|||||||
setMediaType('image');
|
setMediaType('image');
|
||||||
setMediaObjectUrl('');
|
setMediaObjectUrl('');
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
setMediaReferences([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -64,7 +67,7 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
|
|||||||
resetState();
|
resetState();
|
||||||
const defaultWatermark = watermarks.find(w => w.isDefault) || watermarks[0];
|
const defaultWatermark = watermarks.find(w => w.isDefault) || watermarks[0];
|
||||||
setConfig({ ...defaultConfig, watermarkId: defaultWatermark?.id || null });
|
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]);
|
}, [open, watermarks, form]);
|
||||||
|
|
||||||
@@ -136,6 +139,8 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
|
|||||||
file,
|
file,
|
||||||
mediaType,
|
mediaType,
|
||||||
title: values.title?.trim() || null,
|
title: values.title?.trim() || null,
|
||||||
|
generationPrompt: values.generation_prompt?.trim() || null,
|
||||||
|
mediaReferences,
|
||||||
watermarkType: config.watermarkType,
|
watermarkType: config.watermarkType,
|
||||||
watermarkId: config.watermarkType === 'image' ? config.watermarkId : null,
|
watermarkId: config.watermarkType === 'image' ? config.watermarkId : null,
|
||||||
opacityLevel: config.opacityLevel,
|
opacityLevel: config.opacityLevel,
|
||||||
@@ -197,6 +202,12 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
|
|||||||
</Upload>
|
</Upload>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="title" label="素材标题" extra="选填;不填时后台显示“未命名素材”,不会再自动使用文件名作为标题。"><Input disabled={submitting} /></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="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.Item name="is_active" label="前台展示" valuePropName="checked"><Switch disabled={submitting} /></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { Button, Card, Image, Input, InputNumber, Space, Tag, Upload, message } from 'antd';
|
||||||
|
import { DeleteOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
|
import { uploadAdminFile } from '../../api';
|
||||||
|
import type { AdminUploadResourceType, HomeMaterialMediaReference } from '../../types';
|
||||||
|
import { apiUrl } from '../../utils/resourceUrl';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: HomeMaterialMediaReference[] | null;
|
||||||
|
onChange: (value: HomeMaterialMediaReference[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaTypeText: Record<string, string> = {
|
||||||
|
image: '图片',
|
||||||
|
video: '视频',
|
||||||
|
audio: '音频',
|
||||||
|
};
|
||||||
|
|
||||||
|
function detectResourceType(file: File): AdminUploadResourceType | null {
|
||||||
|
if (file.type.startsWith('image/')) return 'image';
|
||||||
|
if (file.type.startsWith('video/')) return 'video';
|
||||||
|
if (file.type.startsWith('audio/')) return 'audio';
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
if (/\.(jpg|jpeg|png|webp|gif)$/.test(name)) return 'image';
|
||||||
|
if (/\.(mp4|mov|m4v|webm)$/.test(name)) return 'video';
|
||||||
|
if (/\.(mp3|wav|m4a|aac)$/.test(name)) return 'audio';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDuration(file: File, resourceType: AdminUploadResourceType): Promise<number | null> {
|
||||||
|
if (resourceType !== 'video' && resourceType !== 'audio') return Promise.resolve(null);
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const el = document.createElement(resourceType === 'video' ? 'video' : 'audio');
|
||||||
|
el.preload = 'metadata';
|
||||||
|
el.onloadedmetadata = () => {
|
||||||
|
const duration = Number.isFinite(el.duration) ? Number(el.duration.toFixed(3)) : null;
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(duration);
|
||||||
|
};
|
||||||
|
el.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(null);
|
||||||
|
};
|
||||||
|
el.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const MediaReferencesEditor: React.FC<Props> = ({ value, onChange, disabled }) => {
|
||||||
|
const refs = useMemo(() => value || [], [value]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
const updateItem = (index: number, patch: Partial<HomeMaterialMediaReference>) => {
|
||||||
|
onChange(refs.map((item, idx) => idx === index ? { ...item, ...patch } : item));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (index: number) => {
|
||||||
|
onChange(refs.filter((_, idx) => idx !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFile = async (file: File) => {
|
||||||
|
const resourceType = detectResourceType(file);
|
||||||
|
if (!resourceType || !['image', 'video', 'audio'].includes(resourceType)) {
|
||||||
|
message.warning('附件仅支持图片、视频、音频');
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const durationSeconds = await readDuration(file, resourceType);
|
||||||
|
const res = await uploadAdminFile(file, {
|
||||||
|
scene: 'home_material_reference',
|
||||||
|
resourceType,
|
||||||
|
durationSeconds,
|
||||||
|
});
|
||||||
|
const ref = res.mediaReference;
|
||||||
|
if (!ref || !['image', 'video', 'audio'].includes(ref.type)) {
|
||||||
|
throw new Error('上传返回附件格式异常');
|
||||||
|
}
|
||||||
|
onChange([
|
||||||
|
...refs,
|
||||||
|
{
|
||||||
|
url: ref.url,
|
||||||
|
type: ref.type as 'image' | 'video' | 'audio',
|
||||||
|
name: ref.name || file.name,
|
||||||
|
duration: ref.duration ?? durationSeconds,
|
||||||
|
source: ref.source,
|
||||||
|
uploadResourceId: ref.uploadResourceId,
|
||||||
|
displayUrl: ref.displayUrl || ref.url,
|
||||||
|
previewUrl: ref.previewUrl || ref.displayUrl || ref.url,
|
||||||
|
role: ref.role || `reference_${ref.type}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
message.success('附件上传成功');
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '附件上传失败');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||||
|
<Upload beforeUpload={(file) => uploadFile(file as File)} showUploadList={false} disabled={disabled || uploading} accept="image/*,video/*,audio/*">
|
||||||
|
<Button icon={<UploadOutlined />} loading={uploading} disabled={disabled || uploading}>上传附件</Button>
|
||||||
|
</Upload>
|
||||||
|
{refs.length === 0 ? (
|
||||||
|
<div style={{ color: '#999', fontSize: 13 }}>暂无附件。可上传图片、视频、音频,保存后会随前台素材接口返回。</div>
|
||||||
|
) : (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size={10}>
|
||||||
|
{refs.map((ref, index) => {
|
||||||
|
const previewUrl = apiUrl(ref.previewUrl || ref.displayUrl || ref.url);
|
||||||
|
const displayUrl = apiUrl(ref.displayUrl || ref.url);
|
||||||
|
return (
|
||||||
|
<Card key={`${ref.url}-${index}`} size="small" bodyStyle={{ padding: 12 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||||
|
<div style={{ width: 120, minHeight: 72, flexShrink: 0, borderRadius: 8, overflow: 'hidden', background: '#f6f7fb', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{ref.type === 'image' && <Image width={120} height={72} style={{ objectFit: 'cover' }} src={previewUrl} />}
|
||||||
|
{ref.type === 'video' && <video src={displayUrl} style={{ width: 120, height: 72, objectFit: 'cover', background: '#000' }} controls />}
|
||||||
|
{ref.type === 'audio' && <audio src={displayUrl} controls style={{ width: 112 }} />}
|
||||||
|
</div>
|
||||||
|
<Space direction="vertical" style={{ flex: 1, minWidth: 0 }} size={8}>
|
||||||
|
<Space wrap>
|
||||||
|
<Tag color={ref.type === 'image' ? 'blue' : ref.type === 'video' ? 'purple' : 'green'}>{mediaTypeText[ref.type] || ref.type}</Tag>
|
||||||
|
{ref.uploadResourceId && <Tag>资源ID:{ref.uploadResourceId}</Tag>}
|
||||||
|
</Space>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
value={ref.name || ''}
|
||||||
|
placeholder="附件名称"
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => updateItem(index, { name: e.target.value || null })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
value={ref.role || ''}
|
||||||
|
placeholder="附件角色,如 reference_image"
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => updateItem(index, { role: e.target.value || null })}
|
||||||
|
/>
|
||||||
|
{(ref.type === 'video' || ref.type === 'audio') && (
|
||||||
|
<InputNumber
|
||||||
|
size="small"
|
||||||
|
min={0}
|
||||||
|
precision={3}
|
||||||
|
style={{ width: 180 }}
|
||||||
|
value={ref.duration ?? null}
|
||||||
|
placeholder="时长(秒)"
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(v) => updateItem(index, { duration: v === null ? null : Number(v) })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Input size="small" value={ref.url} disabled />
|
||||||
|
</Space>
|
||||||
|
<Button danger size="small" icon={<DeleteOutlined />} disabled={disabled} onClick={() => removeItem(index)}>删除</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MediaReferencesEditor;
|
||||||
@@ -176,6 +176,8 @@ export interface AdminTeamPayload {
|
|||||||
description?: string | null;
|
description?: string | null;
|
||||||
status: 'active' | 'disabled';
|
status: 'active' | 'disabled';
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
|
generation_prompt?: string | null;
|
||||||
|
media_references?: HomeMaterialMediaReference[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUser {
|
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 HomeMaterialMediaType = 'image' | 'video';
|
||||||
export type HomeMaterialAssetStatus = 'draft' | 'processing' | 'success' | 'failed';
|
export type HomeMaterialAssetStatus = 'draft' | 'processing' | 'success' | 'failed';
|
||||||
export type HomeMaterialWatermarkType = 'image' | 'repeated_text';
|
export type HomeMaterialWatermarkType = 'image' | 'repeated_text';
|
||||||
@@ -998,6 +1047,8 @@ export interface HomeMaterialAsset {
|
|||||||
watermarkId?: string | null;
|
watermarkId?: string | null;
|
||||||
watermarkName?: string | null;
|
watermarkName?: string | null;
|
||||||
watermarkConfig?: HomeMaterialWatermarkConfig | Record<string, unknown> | null;
|
watermarkConfig?: HomeMaterialWatermarkConfig | Record<string, unknown> | null;
|
||||||
|
generationPrompt?: string | null;
|
||||||
|
mediaReferences?: HomeMaterialMediaReference[] | null;
|
||||||
width?: number | null;
|
width?: number | null;
|
||||||
height?: number | null;
|
height?: number | null;
|
||||||
durationSeconds?: number | string | null;
|
durationSeconds?: number | string | null;
|
||||||
@@ -1016,6 +1067,8 @@ export interface HomeMaterialAssetUpdatePayload {
|
|||||||
title?: string | null;
|
title?: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
|
generation_prompt?: string | null;
|
||||||
|
media_references?: HomeMaterialMediaReference[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HomeMaterialAssetStatusOut {
|
export interface HomeMaterialAssetStatusOut {
|
||||||
@@ -1038,6 +1091,8 @@ export interface HomeMaterialUploadResult {
|
|||||||
watermarkedUrl?: string | null;
|
watermarkedUrl?: string | null;
|
||||||
coverUrl?: string | null;
|
coverUrl?: string | null;
|
||||||
watermarkConfig?: HomeMaterialWatermarkConfig | Record<string, unknown> | null;
|
watermarkConfig?: HomeMaterialWatermarkConfig | Record<string, unknown> | null;
|
||||||
|
generationPrompt?: string | null;
|
||||||
|
mediaReferences?: HomeMaterialMediaReference[] | null;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1074,6 +1129,8 @@ export interface HomeMaterialUploadAssetParams {
|
|||||||
file: File;
|
file: File;
|
||||||
mediaType: HomeMaterialMediaType;
|
mediaType: HomeMaterialMediaType;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
|
generationPrompt?: string | null;
|
||||||
|
mediaReferences?: HomeMaterialMediaReference[] | null;
|
||||||
watermarkType: HomeMaterialWatermarkType;
|
watermarkType: HomeMaterialWatermarkType;
|
||||||
watermarkId?: string | null;
|
watermarkId?: string | null;
|
||||||
watermarkFile?: File | null;
|
watermarkFile?: File | null;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/resourceurl.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
|
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/resourceurl.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""2026070901_add_credits_ratio_增加上传图片积分规则
|
||||||
|
|
||||||
|
Revision ID: 2026070901
|
||||||
|
Revises: 6c1aaf036f43
|
||||||
|
Create Date: 2026-07-01 00:00:00.000000
|
||||||
|
|
||||||
|
该文件包含 2026-07-01 的数据库迁移内容:
|
||||||
|
1. 积分规则表增加传入视频计费字段(input_video_ratio, input_video_base_credits, input_video_per_second_credits)
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = '2026070901'
|
||||||
|
down_revision: Union[str, None] = '6c1aaf036f43'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ========================================
|
||||||
|
# 2026-07-01 - 积分规则表增加传入图片计费字段
|
||||||
|
# ========================================
|
||||||
|
op.add_column('credit_ratios', sa.Column('input_image_ratio', sa.Float(), server_default='1.0', nullable=False))
|
||||||
|
op.add_column('credit_ratios', sa.Column('input_image_base_credits', sa.Float(), server_default='0.0', nullable=False))
|
||||||
|
op.add_column('credit_ratios', sa.Column('input_image_per_image_credits', sa.Float(), server_default='0.5', nullable=False))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ========================================
|
||||||
|
# 2026-07-01 - 积分规则表增加传入图片计费字段(回滚)
|
||||||
|
# ========================================
|
||||||
|
op.drop_column('credit_ratios', 'input_image_per_image_credits')
|
||||||
|
op.drop_column('credit_ratios', 'input_image_base_credits')
|
||||||
|
op.drop_column('credit_ratios', 'input_image_ratio')
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""add home material generation config
|
||||||
|
|
||||||
|
Revision ID: 6c1aaf036f43
|
||||||
|
Revises: 1475d11b1d74
|
||||||
|
Create Date: 2026-07-09 10:28:05.067550
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '6c1aaf036f43'
|
||||||
|
down_revision: Union[str, None] = '1475d11b1d74'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('home_material_assets', sa.Column('generation_prompt', sa.Text(), nullable=True, comment='生成提词'))
|
||||||
|
op.add_column('home_material_assets', sa.Column('media_references_json', sa.Text(), nullable=True, comment='附件/参考素材JSON字符串'))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('home_material_assets', 'media_references_json')
|
||||||
|
op.drop_column('home_material_assets', 'generation_prompt')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -7,6 +7,7 @@ from app.api.admin.home_material import router as home_material_router
|
|||||||
from app.api.admin.private_portrait import router as private_portrait_router
|
from app.api.admin.private_portrait import router as private_portrait_router
|
||||||
from app.api.admin.recharge_package import router as recharge_package_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.menu_config import router as menu_config_router
|
||||||
|
from app.api.admin.upload import router as admin_upload_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(video_prompt_schema_config_router)
|
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(private_portrait_router)
|
||||||
router.include_router(recharge_package_router)
|
router.include_router(recharge_package_router)
|
||||||
router.include_router(menu_config_router)
|
router.include_router(menu_config_router)
|
||||||
|
router.include_router(admin_upload_router)
|
||||||
|
|||||||
@@ -336,6 +336,8 @@ async def upload_home_material_asset(
|
|||||||
file: UploadFile = File(..., description="图片或视频素材文件。"),
|
file: UploadFile = File(..., description="图片或视频素材文件。"),
|
||||||
media_type: HomeMaterialMediaType = Form(..., description="素材类型:image图片,video视频。"),
|
media_type: HomeMaterialMediaType = Form(..., description="素材类型:image图片,video视频。"),
|
||||||
title: str | None = Form(None, description="素材标题。为空时不再自动回填文件名。"),
|
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_type: HomeMaterialWatermarkType = Form(HomeMaterialWatermarkType.IMAGE, description="水印类型:image 图片水印;repeated_text 重复文字水印。"),
|
||||||
watermark_id: str | None = Form(None, description="图片水印ID,可选。watermark_type=image 时使用。"),
|
watermark_id: str | None = Form(None, description="图片水印ID,可选。watermark_type=image 时使用。"),
|
||||||
watermark_file: UploadFile | None = File(None, description="临时图片水印,可选。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,
|
file=file,
|
||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
title=title,
|
title=title,
|
||||||
|
generation_prompt=generation_prompt,
|
||||||
|
media_references_json=media_references_json,
|
||||||
watermark_id=watermark_id,
|
watermark_id=watermark_id,
|
||||||
watermark_file=watermark_file,
|
watermark_file=watermark_file,
|
||||||
watermark_config=config,
|
watermark_config=config,
|
||||||
@@ -452,7 +456,7 @@ async def get_home_material_asset_status(
|
|||||||
"/assets/{asset_id}",
|
"/assets/{asset_id}",
|
||||||
response_model=HomeMaterialAssetOut,
|
response_model=HomeMaterialAssetOut,
|
||||||
summary="修改首页素材展示信息",
|
summary="修改首页素材展示信息",
|
||||||
description="只修改行业、标题、启用状态、排序,不重新生成水印。",
|
description="修改行业、标题、启用状态、排序、生成提词和附件JSON,不重新生成水印。",
|
||||||
)
|
)
|
||||||
async def update_home_material_asset(
|
async def update_home_material_asset(
|
||||||
req: HomeMaterialAssetUpdate,
|
req: HomeMaterialAssetUpdate,
|
||||||
@@ -465,7 +469,7 @@ async def update_home_material_asset(
|
|||||||
db,
|
db,
|
||||||
admin.id,
|
admin.id,
|
||||||
admin.username,
|
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",
|
"PUT",
|
||||||
f"/admin/home-material/assets/{asset_id}",
|
f"/admin/home-material/assets/{asset_id}",
|
||||||
detail=_detail(before=before, after=after),
|
detail=_detail(before=before, after=after),
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dependencies import get_admin_user, get_db
|
||||||
|
from app.enums.admin_upload import AdminUploadResourceTypeEnum, AdminUploadSceneEnum
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.admin_upload import AdminUploadFileOut
|
||||||
|
from app.services.admin_upload import admin_upload_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/uploads", tags=["admin-upload"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/files",
|
||||||
|
response_model=AdminUploadFileOut,
|
||||||
|
summary="管理后台全局文件上传",
|
||||||
|
description=(
|
||||||
|
"管理后台纯文件上传统一入口。通过 scene 区分业务场景并落到独立目录;"
|
||||||
|
"首页素材附件使用 scene=home_material_reference,系统Logo使用 system_logo,PDF使用 system_pdf,开户缩略图使用 open_type_thumb。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def upload_admin_file(
|
||||||
|
file: UploadFile = File(..., description="上传文件。"),
|
||||||
|
scene: AdminUploadSceneEnum = Form(..., description="上传场景。"),
|
||||||
|
resource_type: AdminUploadResourceTypeEnum = Form(..., description="资源类型:image/video/audio/pdf/file。"),
|
||||||
|
duration_seconds: float | None = Form(None, ge=0, description="音视频时长,单位秒。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await admin_upload_service.upload_file(
|
||||||
|
db,
|
||||||
|
file=file,
|
||||||
|
scene=scene,
|
||||||
|
resource_type=resource_type,
|
||||||
|
duration_seconds=duration_seconds,
|
||||||
|
admin=admin,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
@@ -1383,6 +1383,7 @@ async def create_credit_ratio(
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
return ratio
|
return ratio
|
||||||
|
|
||||||
|
|
||||||
@@ -1422,6 +1423,7 @@ async def update_credit_ratio(
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
return ratio
|
return ratio
|
||||||
|
|
||||||
|
|
||||||
@@ -1455,6 +1457,7 @@ async def delete_credit_ratio(
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
return {"message": "ok"}
|
return {"message": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from urllib.parse import urlencode, unquote
|
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 fastapi.responses import RedirectResponse
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.private_portrait import PrivatePortraitAsset, PrivatePortraitProject
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.enums.upload_resource import UploadResourceTypeEnum
|
||||||
from app.schemas.private_portrait import (
|
from app.schemas.private_portrait import (
|
||||||
PrivatePortraitAssetCreate,
|
PrivatePortraitAssetCreate,
|
||||||
PrivatePortraitAssetListOut,
|
PrivatePortraitAssetListOut,
|
||||||
@@ -31,6 +32,7 @@ from app.schemas.private_portrait import (
|
|||||||
PrivatePortraitProjectOut,
|
PrivatePortraitProjectOut,
|
||||||
PrivatePortraitProjectUpdate,
|
PrivatePortraitProjectUpdate,
|
||||||
PrivatePortraitSelectableAssetListOut,
|
PrivatePortraitSelectableAssetListOut,
|
||||||
|
PrivatePortraitUploadOut,
|
||||||
PrivatePortraitValidateSessionCreate,
|
PrivatePortraitValidateSessionCreate,
|
||||||
PrivatePortraitValidateSessionOut,
|
PrivatePortraitValidateSessionOut,
|
||||||
build_private_portrait_enum_meta,
|
build_private_portrait_enum_meta,
|
||||||
@@ -55,6 +57,8 @@ from app.services.private_portrait.project_service import (
|
|||||||
refresh_project_counters,
|
refresh_project_counters,
|
||||||
soft_delete_project,
|
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 (
|
from app.services.private_portrait.real_person.service import (
|
||||||
create_real_person_asset,
|
create_real_person_asset,
|
||||||
create_real_person_project,
|
create_real_person_project,
|
||||||
@@ -124,6 +128,62 @@ async def get_private_portrait_enum_meta():
|
|||||||
return build_private_portrait_enum_meta()
|
return build_private_portrait_enum_meta()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/private-portrait/uploads/image",
|
||||||
|
response_model=PrivatePortraitUploadOut,
|
||||||
|
summary="上传真人图片素材",
|
||||||
|
description="上传真人图片素材并写入 UploadResource,module=private_portrait_real。创建素材时需回传 resource_id 到 upload_resource_id。",
|
||||||
|
)
|
||||||
|
async def upload_private_portrait_image(file: UploadFile = File(...), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
out = await upload_private_portrait_asset_file(
|
||||||
|
db,
|
||||||
|
file=file,
|
||||||
|
current_user=current_user,
|
||||||
|
library_type=PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||||
|
resource_type=UploadResourceTypeEnum.IMAGE.value,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return out
|
||||||
|
except HTTPException:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"上传真人图片素材失败: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/private-portrait/uploads/video",
|
||||||
|
response_model=PrivatePortraitUploadOut,
|
||||||
|
summary="上传真人视频素材",
|
||||||
|
description="上传真人视频素材并写入 UploadResource,module=private_portrait_real。创建素材时需回传 resource_id 到 upload_resource_id。",
|
||||||
|
)
|
||||||
|
async def upload_private_portrait_video(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
duration_seconds: float | None = Query(None, description="客户端解析的视频秒数,服务端会写入 UploadResource 并在创建素材时回填"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
out = await upload_private_portrait_asset_file(
|
||||||
|
db,
|
||||||
|
file=file,
|
||||||
|
current_user=current_user,
|
||||||
|
library_type=PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||||
|
resource_type=UploadResourceTypeEnum.VIDEO.value,
|
||||||
|
duration_seconds=duration_seconds,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return out
|
||||||
|
except HTTPException:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"上传真人视频素材失败: {exc}")
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/private-portrait/projects",
|
"/private-portrait/projects",
|
||||||
response_model=PrivatePortraitProjectCreateWithValidateOut,
|
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)):
|
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 = await soft_delete_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||||
project_id_snapshot = project.id
|
project_id_snapshot = project.id
|
||||||
|
pending_upload_resource_ids = list(getattr(project, "_pending_upload_resource_ids", []) or [])
|
||||||
await db.commit()
|
await db.commit()
|
||||||
try:
|
try:
|
||||||
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_project_remote
|
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)
|
_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:
|
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)
|
_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)
|
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 = await soft_delete_asset(db, user_id=current_user.id, asset_id=asset_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||||
asset_id_snapshot = asset.id
|
asset_id_snapshot = asset.id
|
||||||
project_id_snapshot = asset.project_id
|
project_id_snapshot = asset.project_id
|
||||||
|
pending_upload_resource_ids = list(getattr(asset, "_pending_upload_resource_ids", []) or [])
|
||||||
await db.commit()
|
await db.commit()
|
||||||
try:
|
try:
|
||||||
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_asset_remote
|
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)
|
_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:
|
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)
|
_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)
|
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
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 import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.private_portrait import PrivatePortraitAsset, PrivatePortraitProject
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.enums.upload_resource import UploadResourceTypeEnum
|
||||||
from app.schemas.private_portrait import (
|
from app.schemas.private_portrait import (
|
||||||
PrivatePortraitAssetCreate,
|
PrivatePortraitAssetCreate,
|
||||||
PrivatePortraitAssetListOut,
|
PrivatePortraitAssetListOut,
|
||||||
@@ -26,6 +27,7 @@ from app.schemas.private_portrait import (
|
|||||||
PrivatePortraitProjectOut,
|
PrivatePortraitProjectOut,
|
||||||
PrivatePortraitProjectUpdate,
|
PrivatePortraitProjectUpdate,
|
||||||
PrivatePortraitSelectableAssetListOut,
|
PrivatePortraitSelectableAssetListOut,
|
||||||
|
PrivatePortraitUploadOut,
|
||||||
PrivatePortraitVirtualProjectCreate,
|
PrivatePortraitVirtualProjectCreate,
|
||||||
build_private_portrait_enum_meta,
|
build_private_portrait_enum_meta,
|
||||||
)
|
)
|
||||||
@@ -46,6 +48,8 @@ from app.services.private_portrait.project_service import (
|
|||||||
refresh_project_counters,
|
refresh_project_counters,
|
||||||
soft_delete_project,
|
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
|
from app.services.private_portrait.virtual.service import create_virtual_asset, create_virtual_project, update_virtual_project
|
||||||
|
|
||||||
router = APIRouter(tags=["私域虚拟人像素材库"])
|
router = APIRouter(tags=["私域虚拟人像素材库"])
|
||||||
@@ -103,6 +107,62 @@ async def get_virtual_private_portrait_enum_meta():
|
|||||||
return build_private_portrait_enum_meta()
|
return build_private_portrait_enum_meta()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/private-portrait/virtual/uploads/image",
|
||||||
|
response_model=PrivatePortraitUploadOut,
|
||||||
|
summary="上传虚拟图片素材",
|
||||||
|
description="上传虚拟图片素材并写入 UploadResource,module=private_portrait_virtual。创建素材时需回传 resource_id 到 upload_resource_id。",
|
||||||
|
)
|
||||||
|
async def upload_private_portrait_virtual_image(file: UploadFile = File(...), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
out = await upload_private_portrait_asset_file(
|
||||||
|
db,
|
||||||
|
file=file,
|
||||||
|
current_user=current_user,
|
||||||
|
library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value,
|
||||||
|
resource_type=UploadResourceTypeEnum.IMAGE.value,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return out
|
||||||
|
except HTTPException:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"上传虚拟图片素材失败: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/private-portrait/virtual/uploads/video",
|
||||||
|
response_model=PrivatePortraitUploadOut,
|
||||||
|
summary="上传虚拟视频素材",
|
||||||
|
description="上传虚拟视频素材并写入 UploadResource,module=private_portrait_virtual。创建素材时需回传 resource_id 到 upload_resource_id。",
|
||||||
|
)
|
||||||
|
async def upload_private_portrait_virtual_video(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
duration_seconds: float | None = Query(None, description="客户端解析的视频秒数,服务端会写入 UploadResource 并在创建素材时回填"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
out = await upload_private_portrait_asset_file(
|
||||||
|
db,
|
||||||
|
file=file,
|
||||||
|
current_user=current_user,
|
||||||
|
library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value,
|
||||||
|
resource_type=UploadResourceTypeEnum.VIDEO.value,
|
||||||
|
duration_seconds=duration_seconds,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return out
|
||||||
|
except HTTPException:
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"上传虚拟视频素材失败: {exc}")
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/private-portrait/virtual-projects",
|
"/private-portrait/virtual-projects",
|
||||||
response_model=PrivatePortraitProjectOut,
|
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)):
|
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 = 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
|
project_id_snapshot = project.id
|
||||||
|
pending_upload_resource_ids = list(getattr(project, "_pending_upload_resource_ids", []) or [])
|
||||||
await db.commit()
|
await db.commit()
|
||||||
try:
|
try:
|
||||||
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_project_remote
|
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)
|
_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:
|
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)
|
_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)
|
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 = 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
|
asset_id_snapshot = asset.id
|
||||||
project_id_snapshot = asset.project_id
|
project_id_snapshot = asset.project_id
|
||||||
|
pending_upload_resource_ids = list(getattr(asset, "_pending_upload_resource_ids", []) or [])
|
||||||
await db.commit()
|
await db.commit()
|
||||||
try:
|
try:
|
||||||
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_asset_remote
|
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)
|
_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:
|
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)
|
_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)
|
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 import inspect as sa_inspect
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
from app.dependencies import get_current_user, get_db
|
from app.dependencies import get_current_user, get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.enums.shot_replicate import (
|
from app.enums.shot_replicate import (
|
||||||
@@ -30,6 +31,8 @@ from app.schemas.shot_replicate import (
|
|||||||
ShotReplicateImagePromptUpdateRequest,
|
ShotReplicateImagePromptUpdateRequest,
|
||||||
ShotReanalyzeOut,
|
ShotReanalyzeOut,
|
||||||
ShotReanalyzeRequest,
|
ShotReanalyzeRequest,
|
||||||
|
ShotSegmentSplitRetryOut,
|
||||||
|
ShotSplitRetryRequest,
|
||||||
ShotReplicateMaterialUpdateRequest,
|
ShotReplicateMaterialUpdateRequest,
|
||||||
ShotReplicateSpecOut,
|
ShotReplicateSpecOut,
|
||||||
ShotReplicateTaskDetailOut,
|
ShotReplicateTaskDetailOut,
|
||||||
@@ -71,6 +74,7 @@ from app.services.shot_replicate_taskset_service import (
|
|||||||
list_task_sets,
|
list_task_sets,
|
||||||
prepare_reanalyze_segment,
|
prepare_reanalyze_segment,
|
||||||
prepare_reanalyze_task_set,
|
prepare_reanalyze_task_set,
|
||||||
|
prepare_retry_split_segment,
|
||||||
segment_detail,
|
segment_detail,
|
||||||
task_set_detail,
|
task_set_detail,
|
||||||
)
|
)
|
||||||
@@ -685,6 +689,83 @@ async def reanalyze_segment(
|
|||||||
return out
|
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(
|
@router.delete(
|
||||||
"/segments/{segment_id}",
|
"/segments/{segment_id}",
|
||||||
response_model=ShotSegmentDeleteOut,
|
response_model=ShotSegmentDeleteOut,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUploadSceneEnum(StrEnum):
|
||||||
|
"""管理后台全局上传场景。"""
|
||||||
|
|
||||||
|
SYSTEM_LOGO = "system_logo"
|
||||||
|
SYSTEM_PDF = "system_pdf"
|
||||||
|
OPEN_TYPE_THUMB = "open_type_thumb"
|
||||||
|
HOME_MATERIAL_REFERENCE = "home_material_reference"
|
||||||
|
ADMIN_COMMON = "admin_common"
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUploadResourceTypeEnum(StrEnum):
|
||||||
|
"""管理后台全局上传资源类型。"""
|
||||||
|
|
||||||
|
IMAGE = "image"
|
||||||
|
VIDEO = "video"
|
||||||
|
AUDIO = "audio"
|
||||||
|
PDF = "pdf"
|
||||||
|
FILE = "file"
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUploadLogEventEnum(StrEnum):
|
||||||
|
"""管理后台上传步骤日志事件。"""
|
||||||
|
|
||||||
|
VALIDATE_STARTED = "ADMIN_UPLOAD_VALIDATE_STARTED"
|
||||||
|
VALIDATE_SUCCESS = "ADMIN_UPLOAD_VALIDATE_SUCCESS"
|
||||||
|
SAVE_STARTED = "ADMIN_UPLOAD_SAVE_STARTED"
|
||||||
|
SAVE_SUCCESS = "ADMIN_UPLOAD_SAVE_SUCCESS"
|
||||||
|
DB_RECORD_SUCCESS = "ADMIN_UPLOAD_DB_RECORD_SUCCESS"
|
||||||
|
FAILED = "ADMIN_UPLOAD_FAILED"
|
||||||
|
|
||||||
|
|
||||||
|
ADMIN_UPLOAD_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
|
||||||
|
ADMIN_UPLOAD_VIDEO_EXTENSIONS = {".mp4", ".mov", ".m4v", ".webm"}
|
||||||
|
ADMIN_UPLOAD_AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac"}
|
||||||
|
ADMIN_UPLOAD_PDF_EXTENSIONS = {".pdf"}
|
||||||
|
|
||||||
|
ADMIN_UPLOAD_IMAGE_MAX_BYTES = 20 * 1024 * 1024
|
||||||
|
ADMIN_UPLOAD_VIDEO_MAX_BYTES = 300 * 1024 * 1024
|
||||||
|
ADMIN_UPLOAD_AUDIO_MAX_BYTES = 30 * 1024 * 1024
|
||||||
|
ADMIN_UPLOAD_PDF_MAX_BYTES = 20 * 1024 * 1024
|
||||||
|
ADMIN_UPLOAD_FILE_MAX_BYTES = 100 * 1024 * 1024
|
||||||
|
ADMIN_UPLOAD_FILE_NAME_MAX_LEN = 255
|
||||||
|
|
||||||
|
ADMIN_UPLOAD_SCENE_LABELS: dict[str, str] = {
|
||||||
|
AdminUploadSceneEnum.SYSTEM_LOGO.value: "系统Logo",
|
||||||
|
AdminUploadSceneEnum.SYSTEM_PDF.value: "系统PDF",
|
||||||
|
AdminUploadSceneEnum.OPEN_TYPE_THUMB.value: "开户方式缩略图",
|
||||||
|
AdminUploadSceneEnum.HOME_MATERIAL_REFERENCE.value: "首页素材附件",
|
||||||
|
AdminUploadSceneEnum.ADMIN_COMMON.value: "后台公共上传",
|
||||||
|
}
|
||||||
@@ -63,6 +63,17 @@ class HomeMaterialPublicResponseMode(StrEnum):
|
|||||||
FLAT = "flat"
|
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):
|
class HomeMaterialOperationEnum(StrEnum):
|
||||||
"""后台操作日志 action 枚举。"""
|
"""后台操作日志 action 枚举。"""
|
||||||
|
|
||||||
@@ -75,6 +86,7 @@ class HomeMaterialOperationEnum(StrEnum):
|
|||||||
WATERMARK_DELETE = "删除首页素材水印"
|
WATERMARK_DELETE = "删除首页素材水印"
|
||||||
ASSET_UPLOAD = "上传首页素材"
|
ASSET_UPLOAD = "上传首页素材"
|
||||||
ASSET_UPDATE = "修改首页素材"
|
ASSET_UPDATE = "修改首页素材"
|
||||||
|
ASSET_GENERATION_CONFIG_UPDATE = "修改首页素材生成配置"
|
||||||
ASSET_DELETE = "删除首页素材"
|
ASSET_DELETE = "删除首页素材"
|
||||||
ASSET_REGENERATE_WATERMARK = "重新生成首页素材水印"
|
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_VIDEO_EXTENSIONS = {".mp4", ".mov", ".m4v", ".webm"}
|
||||||
HOME_MATERIAL_WATERMARK_EXTENSIONS = {".png", ".webp", ".jpg", ".jpeg"}
|
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 = {
|
HOME_MATERIAL_DEFAULT_CONFIG = {
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
"title": "行业素材案例",
|
"title": "行业素材案例",
|
||||||
|
|||||||
@@ -178,6 +178,15 @@ class PrivatePortraitEventType(str, Enum):
|
|||||||
ASSET_CREATE_START = "ASSET_CREATE_START"
|
ASSET_CREATE_START = "ASSET_CREATE_START"
|
||||||
ASSET_CREATE_SUCCESS = "ASSET_CREATE_SUCCESS"
|
ASSET_CREATE_SUCCESS = "ASSET_CREATE_SUCCESS"
|
||||||
ASSET_CREATE_FAILED = "ASSET_CREATE_FAILED"
|
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_START = "ASSET_SYNC_START"
|
||||||
ASSET_SYNC_SUCCESS = "ASSET_SYNC_SUCCESS"
|
ASSET_SYNC_SUCCESS = "ASSET_SYNC_SUCCESS"
|
||||||
|
|||||||
@@ -133,6 +133,10 @@ class ShotReplicateLogEventEnum(StrEnum):
|
|||||||
SPLIT_BY_AI_SUBMITTED = "SHOT_SPLIT_BY_AI_SUBMITTED"
|
SPLIT_BY_AI_SUBMITTED = "SHOT_SPLIT_BY_AI_SUBMITTED"
|
||||||
SPLIT_CUSTOM_SUBMITTED = "SHOT_SPLIT_CUSTOM_SUBMITTED"
|
SPLIT_CUSTOM_SUBMITTED = "SHOT_SPLIT_CUSTOM_SUBMITTED"
|
||||||
SEGMENT_DELETED = "SHOT_SEGMENT_DELETED"
|
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):
|
class ShotReplicateRemoteActionEnum(StrEnum):
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ class UploadResourceModuleEnum(StrEnum):
|
|||||||
"""上传资源所属业务模块。"""
|
"""上传资源所属业务模块。"""
|
||||||
|
|
||||||
COMMON = "common"
|
COMMON = "common"
|
||||||
|
ADMIN_UPLOAD = "admin_upload"
|
||||||
|
HOME_MATERIAL = "home_material"
|
||||||
HOT_OPENING_REPLICATE = "hot_opening_replicate"
|
HOT_OPENING_REPLICATE = "hot_opening_replicate"
|
||||||
SHOT_REPLICATE = "shot_replicate"
|
SHOT_REPLICATE = "shot_replicate"
|
||||||
|
PRIVATE_PORTRAIT_REAL = "private_portrait_real"
|
||||||
|
PRIVATE_PORTRAIT_VIRTUAL = "private_portrait_virtual"
|
||||||
|
|
||||||
|
|
||||||
class UploadResourceTypeEnum(StrEnum):
|
class UploadResourceTypeEnum(StrEnum):
|
||||||
@@ -18,6 +22,8 @@ class UploadResourceTypeEnum(StrEnum):
|
|||||||
VIDEO = "video"
|
VIDEO = "video"
|
||||||
AUDIO = "audio"
|
AUDIO = "audio"
|
||||||
SHOT_SEGMENT = "shot_segment"
|
SHOT_SEGMENT = "shot_segment"
|
||||||
|
PDF = "pdf"
|
||||||
|
FILE = "file"
|
||||||
|
|
||||||
|
|
||||||
class UploadResourceBindStatusEnum(StrEnum):
|
class UploadResourceBindStatusEnum(StrEnum):
|
||||||
@@ -76,6 +82,10 @@ class UploadResourceSourceModelEnum(StrEnum):
|
|||||||
MODULE_GENERATION_PROJECT = "ModuleGenerationProject"
|
MODULE_GENERATION_PROJECT = "ModuleGenerationProject"
|
||||||
SHOT_REPLICATE_TASK_SET = "ShotReplicateTaskSet"
|
SHOT_REPLICATE_TASK_SET = "ShotReplicateTaskSet"
|
||||||
SHOT_REPLICATE_SEGMENT = "ShotReplicateSegment"
|
SHOT_REPLICATE_SEGMENT = "ShotReplicateSegment"
|
||||||
|
HOME_MATERIAL_ASSET = "HomeMaterialAsset"
|
||||||
|
SYSTEM_CONFIG = "SystemConfig"
|
||||||
|
OPEN_TYPE = "OpenType"
|
||||||
|
PRIVATE_PORTRAIT_ASSET = "PrivatePortraitAsset"
|
||||||
|
|
||||||
|
|
||||||
class UploadResourceEventEnum(StrEnum):
|
class UploadResourceEventEnum(StrEnum):
|
||||||
@@ -105,6 +115,7 @@ class UploadResourceEventEnum(StrEnum):
|
|||||||
BIND_SUCCESS = "bind_success"
|
BIND_SUCCESS = "bind_success"
|
||||||
BIND_CONFLICT = "bind_conflict"
|
BIND_CONFLICT = "bind_conflict"
|
||||||
BIND_SKIPPED = "bind_skipped"
|
BIND_SKIPPED = "bind_skipped"
|
||||||
|
BIND_FAILED = "bind_failed"
|
||||||
|
|
||||||
BACKFILL_START = "backfill_start"
|
BACKFILL_START = "backfill_start"
|
||||||
BACKFILL_FILE_MATCHED = "backfill_file_matched"
|
BACKFILL_FILE_MATCHED = "backfill_file_matched"
|
||||||
@@ -155,8 +166,12 @@ class UploadResourceEventEnum(StrEnum):
|
|||||||
|
|
||||||
UPLOAD_RESOURCE_MODULE_LABELS: dict[str, str] = {
|
UPLOAD_RESOURCE_MODULE_LABELS: dict[str, str] = {
|
||||||
UploadResourceModuleEnum.COMMON.value: "普通上传",
|
UploadResourceModuleEnum.COMMON.value: "普通上传",
|
||||||
|
UploadResourceModuleEnum.ADMIN_UPLOAD.value: "管理后台上传",
|
||||||
|
UploadResourceModuleEnum.HOME_MATERIAL.value: "首页素材",
|
||||||
UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value: "爆款开头复刻",
|
UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value: "爆款开头复刻",
|
||||||
UploadResourceModuleEnum.SHOT_REPLICATE.value: "拆镜复刻",
|
UploadResourceModuleEnum.SHOT_REPLICATE.value: "拆镜复刻",
|
||||||
|
UploadResourceModuleEnum.PRIVATE_PORTRAIT_REAL.value: "私域真人素材",
|
||||||
|
UploadResourceModuleEnum.PRIVATE_PORTRAIT_VIRTUAL.value: "私域虚拟素材",
|
||||||
}
|
}
|
||||||
|
|
||||||
UPLOAD_RESOURCE_TYPE_LABELS: dict[str, str] = {
|
UPLOAD_RESOURCE_TYPE_LABELS: dict[str, str] = {
|
||||||
@@ -164,4 +179,6 @@ UPLOAD_RESOURCE_TYPE_LABELS: dict[str, str] = {
|
|||||||
UploadResourceTypeEnum.VIDEO.value: "视频",
|
UploadResourceTypeEnum.VIDEO.value: "视频",
|
||||||
UploadResourceTypeEnum.AUDIO.value: "音频",
|
UploadResourceTypeEnum.AUDIO.value: "音频",
|
||||||
UploadResourceTypeEnum.SHOT_SEGMENT.value: "拆镜切片",
|
UploadResourceTypeEnum.SHOT_SEGMENT.value: "拆镜切片",
|
||||||
|
UploadResourceTypeEnum.PDF.value: "PDF",
|
||||||
|
UploadResourceTypeEnum.FILE.value: "文件",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ async def lifespan(app: FastAPI):
|
|||||||
os.makedirs(settings.UPLOAD_LOCAL_PATH, exist_ok=True)
|
os.makedirs(settings.UPLOAD_LOCAL_PATH, exist_ok=True)
|
||||||
await init_database()
|
await init_database()
|
||||||
await init_redis()
|
await init_redis()
|
||||||
await _seed_data()
|
# await _seed_data()
|
||||||
|
|
||||||
# Start task queue (handles both video and image generation)
|
# Start task queue (handles both video and image generation)
|
||||||
from app.services.video_queue import task_queue
|
from app.services.video_queue import task_queue
|
||||||
|
|||||||
@@ -25,3 +25,6 @@ class CreditRatio(Base, TimestampMixin):
|
|||||||
input_video_ratio: Mapped[float] = mapped_column(Float, default=1.0)
|
input_video_ratio: Mapped[float] = mapped_column(Float, default=1.0)
|
||||||
input_video_base_credits: Mapped[float] = mapped_column(Float, default=0.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_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="视频封面本地路径")
|
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_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")
|
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(
|
status: Mapped[str] = mapped_column(
|
||||||
String(32),
|
String(32),
|
||||||
default=HomeMaterialAssetStatus.DRAFT.value,
|
default=HomeMaterialAssetStatus.DRAFT.value,
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.enums.admin_upload import AdminUploadResourceTypeEnum, AdminUploadSceneEnum
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUploadMediaReferenceOut(BaseModel):
|
||||||
|
url: str = Field(..., description="附件URL。")
|
||||||
|
type: str = Field(..., description="附件类型:image/video/audio/pdf/file。")
|
||||||
|
name: str | None = Field(None, description="附件名称。")
|
||||||
|
duration: float | None = Field(None, description="音视频时长,单位秒。")
|
||||||
|
source: str = Field("admin_upload", description="来源标识。")
|
||||||
|
upload_resource_id: str | None = Field(None, description="UploadResource资源ID。")
|
||||||
|
display_url: str | None = Field(None, description="展示URL。")
|
||||||
|
preview_url: str | None = Field(None, description="预览URL。")
|
||||||
|
role: str | None = Field(None, description="参考素材角色。")
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUploadFileOut(BaseModel):
|
||||||
|
resource_id: str = Field(..., description="UploadResource资源ID。")
|
||||||
|
scene: AdminUploadSceneEnum = Field(..., description="上传场景。")
|
||||||
|
module: str = Field(..., description="上传资源模块。")
|
||||||
|
resource_type: AdminUploadResourceTypeEnum = Field(..., description="资源类型。")
|
||||||
|
url: str = Field(..., description="文件URL。")
|
||||||
|
file_name: str = Field(..., description="保存后的文件名。")
|
||||||
|
original_file_name: str | None = Field(None, description="原始文件名。")
|
||||||
|
file_size_bytes: int = Field(0, description="文件大小,单位字节。")
|
||||||
|
duration_seconds: float | None = Field(None, description="音视频时长,单位秒。")
|
||||||
|
media_reference: AdminUploadMediaReferenceOut | None = Field(None, description="兼容生成任务 media_references 的附件引用对象。")
|
||||||
@@ -61,6 +61,24 @@ class CreditRatioCreate(BaseModel):
|
|||||||
description="传入视频每秒积分。视频生成时,用户上传参考视频每秒消耗的积分",
|
description="传入视频每秒积分。视频生成时,用户上传参考视频每秒消耗的积分",
|
||||||
examples=[0.5],
|
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):
|
class CreditRatioOut(CreditRatioCreate):
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ from __future__ import annotations
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
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 (
|
from app.enums.home_material import (
|
||||||
|
HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN,
|
||||||
|
HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT,
|
||||||
HomeMaterialAssetStatus,
|
HomeMaterialAssetStatus,
|
||||||
HomeMaterialMediaType,
|
HomeMaterialMediaType,
|
||||||
HomeMaterialPublicResponseMode,
|
HomeMaterialPublicResponseMode,
|
||||||
@@ -92,6 +94,40 @@ class HomeMaterialWatermarkListOut(BaseModel):
|
|||||||
total: int = Field(0, description="符合条件的水印总数。")
|
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):
|
class HomeMaterialTextWatermarkConfig(BaseModel):
|
||||||
"""重复文字水印配置。字体固定由后端 HOME_MATERIAL_TEXT_WATERMARK_FONT 指定,不允许前端传字体,避免版权和一致性问题。"""
|
"""重复文字水印配置。字体固定由后端 HOME_MATERIAL_TEXT_WATERMARK_FONT 指定,不允许前端传字体,避免版权和一致性问题。"""
|
||||||
|
|
||||||
@@ -180,6 +216,8 @@ class HomeMaterialAssetOut(BaseModel):
|
|||||||
watermark_id: str | None = Field(None, description="图片水印ID。重复文字水印素材为空。")
|
watermark_id: str | None = Field(None, description="图片水印ID。重复文字水印素材为空。")
|
||||||
watermark_name: str | None = Field(None, description="图片水印名称,列表接口批量查询回填。重复文字水印显示为空。")
|
watermark_name: str | None = Field(None, description="图片水印名称,列表接口批量查询回填。重复文字水印显示为空。")
|
||||||
watermark_config: HomeMaterialWatermarkConfig | dict[str, Any] | 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="素材宽度。")
|
width: int | None = Field(None, description="素材宽度。")
|
||||||
height: int | None = Field(None, description="素材高度。")
|
height: int | None = Field(None, description="素材高度。")
|
||||||
duration_seconds: Decimal | float | 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="素材标题。")
|
title: str | None = Field(None, max_length=128, description="素材标题。")
|
||||||
is_active: bool = Field(True, description="是否前台展示。")
|
is_active: bool = Field(True, description="是否前台展示。")
|
||||||
sort_order: int = Field(0, ge=0, le=999999, 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
|
@classmethod
|
||||||
def clean_title(cls, value: str | None) -> str | None:
|
def clean_text(cls, value: str | None) -> str | None:
|
||||||
value = (value or "").strip()
|
value = (value or "").strip()
|
||||||
return value or None
|
return value or None
|
||||||
|
|
||||||
@@ -256,6 +296,8 @@ class HomeMaterialPublicAssetOut(BaseModel):
|
|||||||
width: int | None = Field(None, description="宽度。")
|
width: int | None = Field(None, description="宽度。")
|
||||||
height: int | None = Field(None, description="高度。")
|
height: int | None = Field(None, description="高度。")
|
||||||
duration_seconds: Decimal | float | 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="排序。")
|
sort_order: int = Field(0, description="排序。")
|
||||||
|
|
||||||
|
|
||||||
@@ -303,4 +345,6 @@ class HomeMaterialUploadResultOut(BaseModel):
|
|||||||
watermarked_url: str | None = Field(None, description="水印素材URL。")
|
watermarked_url: str | None = Field(None, description="水印素材URL。")
|
||||||
cover_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="水印配置快照。")
|
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="提示消息。")
|
message: str = Field("", description="提示消息。")
|
||||||
|
|||||||
@@ -241,12 +241,27 @@ class PrivatePortraitAssetGroupOut(BaseModel):
|
|||||||
model_config = {"from_attributes": True}
|
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):
|
class PrivatePortraitAssetCreate(BaseModel):
|
||||||
url: str = Field(
|
url: str = Field(
|
||||||
...,
|
...,
|
||||||
min_length=1,
|
min_length=1,
|
||||||
description="已上传到本系统且可公网访问的素材 URL。支持图片/视频,后端会转换为公网地址后调用火山 CreateAsset。",
|
description="已上传到本系统且可公网访问的素材 URL。支持图片/视频,后端会转换为公网地址后调用火山 CreateAsset。",
|
||||||
)
|
)
|
||||||
|
upload_resource_id: str | None = Field(
|
||||||
|
None,
|
||||||
|
max_length=32,
|
||||||
|
description="可选但新客户端必须传:真人/虚拟专用上传接口返回的 UploadResource.id。后端 CreateAsset 成功后会绑定到 PrivatePortraitAsset。",
|
||||||
|
)
|
||||||
asset_type: str = Field(
|
asset_type: str = Field(
|
||||||
default=PrivatePortraitAssetType.IMAGE.value,
|
default=PrivatePortraitAssetType.IMAGE.value,
|
||||||
description="素材类型枚举:Image=图片,Video=视频,Audio=音频。当前业务仅开放 Image / Video,Audio 会被拒绝。",
|
description="素材类型枚举:Image=图片,Video=视频,Audio=音频。当前业务仅开放 Image / Video,Audio 会被拒绝。",
|
||||||
@@ -273,10 +288,10 @@ class PrivatePortraitAssetCreate(BaseModel):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_video_duration(self) -> "PrivatePortraitAssetCreate":
|
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
|
duration = self.video_duration
|
||||||
if duration is None:
|
|
||||||
raise ValueError("Video 素材必须提供 video_duration")
|
|
||||||
if duration < PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS:
|
if duration < PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS:
|
||||||
raise ValueError(f"视频素材最短不能少于 {PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS} 秒")
|
raise ValueError(f"视频素材最短不能少于 {PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS} 秒")
|
||||||
if duration > PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS:
|
if duration > PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS:
|
||||||
|
|||||||
@@ -740,6 +740,27 @@ class ShotReanalyzeOut(BaseModel):
|
|||||||
analysis_status: str = Field(..., description="重置后的分析状态")
|
analysis_status: str = Field(..., description="重置后的分析状态")
|
||||||
celery_task_name: str = Field(..., description="已投递或待投递的 Celery 任务名")
|
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):
|
class ShotSplitByAIOut(BaseModel):
|
||||||
task_set_id: str = Field(..., description="拆镜总任务集ID")
|
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")
|
status: str = Field(..., description="总任务状态:pending_analysis/analyzing/analysis_completed/analysis_failed/splitting/split_completed/partial_failed/failed/deleted")
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class UploadResourceHistoryItemOut(BaseModel):
|
|||||||
history_source: Literal["upload_resource"] = Field("upload_resource", description="素材云历史来源固定为 upload_resource")
|
history_source: Literal["upload_resource"] = Field("upload_resource", description="素材云历史来源固定为 upload_resource")
|
||||||
history_source_label: str = Field("历史上传素材", description="素材云历史来源中文名称")
|
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="上传资源所属模块中文名称")
|
module_label: str = Field(..., description="上传资源所属模块中文名称")
|
||||||
resource_type: Literal["image", "video", "audio"] = Field(..., description="资源类型:image=图片,video=视频,audio=音频")
|
resource_type: Literal["image", "video", "audio"] = Field(..., description="资源类型:image=图片,video=视频,audio=音频")
|
||||||
resource_type_label: str = Field(..., description="资源类型中文名称")
|
resource_type_label: str = Field(..., description="资源类型中文名称")
|
||||||
@@ -182,4 +182,6 @@ UPLOAD_RESOURCE_HISTORY_ALLOWED_MODULES = {
|
|||||||
UploadResourceModuleEnum.COMMON.value,
|
UploadResourceModuleEnum.COMMON.value,
|
||||||
UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
|
UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
|
||||||
UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||||
|
UploadResourceModuleEnum.PRIVATE_PORTRAIT_REAL.value,
|
||||||
|
UploadResourceModuleEnum.PRIVATE_PORTRAIT_VIRTUAL.value,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.services.admin_upload.service import admin_upload_service
|
||||||
|
|
||||||
|
__all__ = ["admin_upload_service"]
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import UploadFile
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.enums.admin_upload import AdminUploadLogEventEnum, AdminUploadResourceTypeEnum, AdminUploadSceneEnum
|
||||||
|
from app.enums.common import LogEventStatusEnum, LogSourceEnum
|
||||||
|
from app.enums.upload_resource import (
|
||||||
|
UploadResourceBindStatusEnum,
|
||||||
|
UploadResourceCreatedByEnum,
|
||||||
|
UploadResourceDeletePolicyEnum,
|
||||||
|
UploadResourceDurationSourceEnum,
|
||||||
|
UploadResourceModuleEnum,
|
||||||
|
)
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.admin_upload import AdminUploadFileOut, AdminUploadMediaReferenceOut
|
||||||
|
from app.services.admin_upload.storage import save_upload_file
|
||||||
|
from app.services.operation_log_service import build_exception_detail, log_operation_event
|
||||||
|
from app.services.upload_resource.core_service import record_external_upload_resource
|
||||||
|
|
||||||
|
|
||||||
|
def _duration(value: float | None) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
v = float(value)
|
||||||
|
return round(v, 3) if v >= 0 else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _module_for_scene(scene: AdminUploadSceneEnum) -> str:
|
||||||
|
if scene == AdminUploadSceneEnum.HOME_MATERIAL_REFERENCE:
|
||||||
|
return UploadResourceModuleEnum.HOME_MATERIAL.value
|
||||||
|
return UploadResourceModuleEnum.ADMIN_UPLOAD.value
|
||||||
|
|
||||||
|
|
||||||
|
def _role_for_type(resource_type: AdminUploadResourceTypeEnum) -> str | None:
|
||||||
|
if resource_type == AdminUploadResourceTypeEnum.IMAGE:
|
||||||
|
return "reference_image"
|
||||||
|
if resource_type == AdminUploadResourceTypeEnum.VIDEO:
|
||||||
|
return "reference_video"
|
||||||
|
if resource_type == AdminUploadResourceTypeEnum.AUDIO:
|
||||||
|
return "reference_audio"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _media_reference(*, url: str, resource_type: AdminUploadResourceTypeEnum, name: str | None, duration_seconds: float | None, resource_id: str) -> AdminUploadMediaReferenceOut:
|
||||||
|
return AdminUploadMediaReferenceOut(
|
||||||
|
url=url,
|
||||||
|
type=resource_type.value,
|
||||||
|
name=name,
|
||||||
|
duration=duration_seconds,
|
||||||
|
source="admin_upload",
|
||||||
|
upload_resource_id=resource_id,
|
||||||
|
display_url=url,
|
||||||
|
preview_url=url,
|
||||||
|
role=_role_for_type(resource_type),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _log(event_type: str, *, admin: User, scene: str, resource_type: str, status: str = LogEventStatusEnum.SUCCESS.value, message: str | None = None, detail: dict[str, Any] | None = None, error: str | None = None) -> None:
|
||||||
|
log_operation_event(
|
||||||
|
domain="admin_upload",
|
||||||
|
module="admin_upload",
|
||||||
|
event_type=event_type,
|
||||||
|
event_status=status,
|
||||||
|
source=LogSourceEnum.API.value,
|
||||||
|
user_id=admin.id,
|
||||||
|
message=message,
|
||||||
|
detail={"scene": scene, "resource_type": resource_type, **(detail or {})},
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUploadService:
|
||||||
|
async def upload_file(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
file: UploadFile,
|
||||||
|
scene: AdminUploadSceneEnum,
|
||||||
|
resource_type: AdminUploadResourceTypeEnum,
|
||||||
|
admin: User,
|
||||||
|
duration_seconds: float | None = None,
|
||||||
|
) -> AdminUploadFileOut:
|
||||||
|
duration = _duration(duration_seconds)
|
||||||
|
_log(
|
||||||
|
AdminUploadLogEventEnum.VALIDATE_STARTED.value,
|
||||||
|
admin=admin,
|
||||||
|
scene=scene.value,
|
||||||
|
resource_type=resource_type.value,
|
||||||
|
status=LogEventStatusEnum.STARTED.value,
|
||||||
|
detail={"filename": file.filename, "content_type": file.content_type},
|
||||||
|
)
|
||||||
|
stored = None
|
||||||
|
try:
|
||||||
|
_log(
|
||||||
|
AdminUploadLogEventEnum.VALIDATE_SUCCESS.value,
|
||||||
|
admin=admin,
|
||||||
|
scene=scene.value,
|
||||||
|
resource_type=resource_type.value,
|
||||||
|
detail={"filename": file.filename, "duration_seconds": duration},
|
||||||
|
)
|
||||||
|
_log(
|
||||||
|
AdminUploadLogEventEnum.SAVE_STARTED.value,
|
||||||
|
admin=admin,
|
||||||
|
scene=scene.value,
|
||||||
|
resource_type=resource_type.value,
|
||||||
|
status=LogEventStatusEnum.STARTED.value,
|
||||||
|
detail={"filename": file.filename},
|
||||||
|
)
|
||||||
|
stored = await save_upload_file(file, scene=scene, resource_type=resource_type, admin_id=admin.id)
|
||||||
|
_log(
|
||||||
|
AdminUploadLogEventEnum.SAVE_SUCCESS.value,
|
||||||
|
admin=admin,
|
||||||
|
scene=scene.value,
|
||||||
|
resource_type=resource_type.value,
|
||||||
|
detail={"url": stored.file_url, "storage_path": stored.storage_path, "file_size_bytes": stored.file_size_bytes},
|
||||||
|
)
|
||||||
|
module = _module_for_scene(scene)
|
||||||
|
resource = await record_external_upload_resource(
|
||||||
|
db,
|
||||||
|
user_id=admin.id,
|
||||||
|
module=module,
|
||||||
|
resource_type=resource_type.value,
|
||||||
|
resource_url=stored.file_url,
|
||||||
|
storage_path=stored.storage_path,
|
||||||
|
file_size_bytes=stored.file_size_bytes,
|
||||||
|
file_name=stored.file_name,
|
||||||
|
mime_type=file.content_type,
|
||||||
|
duration_seconds=duration,
|
||||||
|
duration_source=UploadResourceDurationSourceEnum.CLIENT.value if duration is not None else None,
|
||||||
|
bind_status=UploadResourceBindStatusEnum.PENDING.value,
|
||||||
|
delete_policy=UploadResourceDeletePolicyEnum.USER_DELETABLE.value,
|
||||||
|
created_by=UploadResourceCreatedByEnum.API.value,
|
||||||
|
metadata={
|
||||||
|
"scene": scene.value,
|
||||||
|
"original_filename": stored.original_file_name,
|
||||||
|
"client_duration_seconds": duration_seconds,
|
||||||
|
"source": "admin_upload",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await db.refresh(resource)
|
||||||
|
_log(
|
||||||
|
AdminUploadLogEventEnum.DB_RECORD_SUCCESS.value,
|
||||||
|
admin=admin,
|
||||||
|
scene=scene.value,
|
||||||
|
resource_type=resource_type.value,
|
||||||
|
detail={"resource_id": resource.id, "url": stored.file_url, "module": module},
|
||||||
|
)
|
||||||
|
media_reference = _media_reference(
|
||||||
|
url=stored.file_url,
|
||||||
|
resource_type=resource_type,
|
||||||
|
name=stored.original_file_name or stored.file_name,
|
||||||
|
duration_seconds=resource.duration_seconds,
|
||||||
|
resource_id=resource.id,
|
||||||
|
)
|
||||||
|
return AdminUploadFileOut(
|
||||||
|
resource_id=resource.id,
|
||||||
|
scene=scene,
|
||||||
|
module=module,
|
||||||
|
resource_type=resource_type,
|
||||||
|
url=stored.file_url,
|
||||||
|
file_name=stored.file_name,
|
||||||
|
original_file_name=stored.original_file_name,
|
||||||
|
file_size_bytes=stored.file_size_bytes,
|
||||||
|
duration_seconds=resource.duration_seconds,
|
||||||
|
media_reference=media_reference,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_log(
|
||||||
|
AdminUploadLogEventEnum.FAILED.value,
|
||||||
|
admin=admin,
|
||||||
|
scene=scene.value,
|
||||||
|
resource_type=resource_type.value,
|
||||||
|
status=LogEventStatusEnum.FAILED.value,
|
||||||
|
detail=build_exception_detail(exc, {"filename": file.filename}),
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
admin_upload_service = AdminUploadService()
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import HTTPException, UploadFile
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.enums.admin_upload import (
|
||||||
|
ADMIN_UPLOAD_AUDIO_EXTENSIONS,
|
||||||
|
ADMIN_UPLOAD_AUDIO_MAX_BYTES,
|
||||||
|
ADMIN_UPLOAD_FILE_MAX_BYTES,
|
||||||
|
ADMIN_UPLOAD_FILE_NAME_MAX_LEN,
|
||||||
|
ADMIN_UPLOAD_IMAGE_EXTENSIONS,
|
||||||
|
ADMIN_UPLOAD_IMAGE_MAX_BYTES,
|
||||||
|
ADMIN_UPLOAD_PDF_EXTENSIONS,
|
||||||
|
ADMIN_UPLOAD_PDF_MAX_BYTES,
|
||||||
|
ADMIN_UPLOAD_VIDEO_EXTENSIONS,
|
||||||
|
ADMIN_UPLOAD_VIDEO_MAX_BYTES,
|
||||||
|
AdminUploadResourceTypeEnum,
|
||||||
|
AdminUploadSceneEnum,
|
||||||
|
)
|
||||||
|
|
||||||
|
CHUNK_SIZE = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class StoredAdminUploadFile:
|
||||||
|
storage_path: str
|
||||||
|
file_url: str
|
||||||
|
file_name: str
|
||||||
|
original_file_name: str | None
|
||||||
|
file_size_bytes: int
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_root() -> Path:
|
||||||
|
return Path(settings.UPLOAD_LOCAL_PATH).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_original_name(filename: str | None) -> str | None:
|
||||||
|
if not filename:
|
||||||
|
return None
|
||||||
|
name = Path(filename).name.strip()
|
||||||
|
name = re.sub(r"[\\/\r\n\t]+", "_", name)
|
||||||
|
return name[:ADMIN_UPLOAD_FILE_NAME_MAX_LEN] or None
|
||||||
|
|
||||||
|
|
||||||
|
def _ext(filename: str | None) -> str:
|
||||||
|
return Path(filename or "").suffix.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_extensions(resource_type: AdminUploadResourceTypeEnum) -> set[str] | None:
|
||||||
|
if resource_type == AdminUploadResourceTypeEnum.IMAGE:
|
||||||
|
return ADMIN_UPLOAD_IMAGE_EXTENSIONS
|
||||||
|
if resource_type == AdminUploadResourceTypeEnum.VIDEO:
|
||||||
|
return ADMIN_UPLOAD_VIDEO_EXTENSIONS
|
||||||
|
if resource_type == AdminUploadResourceTypeEnum.AUDIO:
|
||||||
|
return ADMIN_UPLOAD_AUDIO_EXTENSIONS
|
||||||
|
if resource_type == AdminUploadResourceTypeEnum.PDF:
|
||||||
|
return ADMIN_UPLOAD_PDF_EXTENSIONS
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _max_bytes(resource_type: AdminUploadResourceTypeEnum) -> int:
|
||||||
|
if resource_type == AdminUploadResourceTypeEnum.IMAGE:
|
||||||
|
return ADMIN_UPLOAD_IMAGE_MAX_BYTES
|
||||||
|
if resource_type == AdminUploadResourceTypeEnum.VIDEO:
|
||||||
|
return ADMIN_UPLOAD_VIDEO_MAX_BYTES
|
||||||
|
if resource_type == AdminUploadResourceTypeEnum.AUDIO:
|
||||||
|
return ADMIN_UPLOAD_AUDIO_MAX_BYTES
|
||||||
|
if resource_type == AdminUploadResourceTypeEnum.PDF:
|
||||||
|
return ADMIN_UPLOAD_PDF_MAX_BYTES
|
||||||
|
return ADMIN_UPLOAD_FILE_MAX_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
def validate_file_name(filename: str | None, resource_type: AdminUploadResourceTypeEnum) -> str:
|
||||||
|
safe_name = _safe_original_name(filename)
|
||||||
|
if not safe_name:
|
||||||
|
raise HTTPException(status_code=400, detail="请选择文件")
|
||||||
|
allowed = _allowed_extensions(resource_type)
|
||||||
|
ext = _ext(safe_name)
|
||||||
|
if allowed is not None and ext not in allowed:
|
||||||
|
if resource_type == AdminUploadResourceTypeEnum.IMAGE:
|
||||||
|
raise HTTPException(status_code=400, detail="仅支持 jpg/jpeg/png/webp/gif 图片")
|
||||||
|
if resource_type == AdminUploadResourceTypeEnum.VIDEO:
|
||||||
|
raise HTTPException(status_code=400, detail="仅支持 mp4/mov/m4v/webm 视频")
|
||||||
|
if resource_type == AdminUploadResourceTypeEnum.AUDIO:
|
||||||
|
raise HTTPException(status_code=400, detail="仅支持 mp3/wav/m4a/aac 音频")
|
||||||
|
if resource_type == AdminUploadResourceTypeEnum.PDF:
|
||||||
|
raise HTTPException(status_code=400, detail="仅支持 PDF 文件")
|
||||||
|
return safe_name
|
||||||
|
|
||||||
|
|
||||||
|
def _kind_dir(resource_type: AdminUploadResourceTypeEnum) -> str:
|
||||||
|
if resource_type == AdminUploadResourceTypeEnum.IMAGE:
|
||||||
|
return "images"
|
||||||
|
if resource_type == AdminUploadResourceTypeEnum.VIDEO:
|
||||||
|
return "videos"
|
||||||
|
if resource_type == AdminUploadResourceTypeEnum.AUDIO:
|
||||||
|
return "audios"
|
||||||
|
return "files"
|
||||||
|
|
||||||
|
|
||||||
|
def _scene_base_dir(scene: AdminUploadSceneEnum, resource_type: AdminUploadResourceTypeEnum) -> Path:
|
||||||
|
if scene == AdminUploadSceneEnum.HOME_MATERIAL_REFERENCE:
|
||||||
|
if resource_type not in {AdminUploadResourceTypeEnum.IMAGE, AdminUploadResourceTypeEnum.VIDEO, AdminUploadResourceTypeEnum.AUDIO}:
|
||||||
|
raise HTTPException(status_code=400, detail="首页素材附件仅支持图片、视频、音频")
|
||||||
|
return Path("home_materials") / "references" / _kind_dir(resource_type)
|
||||||
|
if scene == AdminUploadSceneEnum.SYSTEM_LOGO:
|
||||||
|
if resource_type != AdminUploadResourceTypeEnum.IMAGE:
|
||||||
|
raise HTTPException(status_code=400, detail="系统Logo仅支持图片")
|
||||||
|
return Path("admin_uploads") / "system_logo" / "images"
|
||||||
|
if scene == AdminUploadSceneEnum.SYSTEM_PDF:
|
||||||
|
if resource_type != AdminUploadResourceTypeEnum.PDF:
|
||||||
|
raise HTTPException(status_code=400, detail="系统PDF仅支持PDF文件")
|
||||||
|
return Path("admin_uploads") / "system_pdf" / "files"
|
||||||
|
if scene == AdminUploadSceneEnum.OPEN_TYPE_THUMB:
|
||||||
|
if resource_type != AdminUploadResourceTypeEnum.IMAGE:
|
||||||
|
raise HTTPException(status_code=400, detail="开户方式缩略图仅支持图片")
|
||||||
|
return Path("admin_uploads") / "open_type_thumb" / "images"
|
||||||
|
return Path("admin_uploads") / "common" / _kind_dir(resource_type)
|
||||||
|
|
||||||
|
|
||||||
|
def build_target(scene: AdminUploadSceneEnum, resource_type: AdminUploadResourceTypeEnum, admin_id: str, original_filename: str | None) -> tuple[Path, str, str]:
|
||||||
|
safe_original = validate_file_name(original_filename, resource_type)
|
||||||
|
now = datetime.now()
|
||||||
|
date_dir = now.strftime("%Y/%m/%d")
|
||||||
|
timestamp = now.strftime("%Y%m%d_%H%M%S")
|
||||||
|
suffix = uuid.uuid4().hex[:8]
|
||||||
|
ext = _ext(safe_original)
|
||||||
|
prefix = {
|
||||||
|
AdminUploadResourceTypeEnum.IMAGE: "admin_img",
|
||||||
|
AdminUploadResourceTypeEnum.VIDEO: "admin_video",
|
||||||
|
AdminUploadResourceTypeEnum.AUDIO: "admin_audio",
|
||||||
|
AdminUploadResourceTypeEnum.PDF: "admin_pdf",
|
||||||
|
}.get(resource_type, "admin_file")
|
||||||
|
file_name = f"{prefix}_{admin_id}_{timestamp}_{suffix}{ext}"
|
||||||
|
rel_dir = _scene_base_dir(scene, resource_type) / date_dir
|
||||||
|
storage_path = _upload_root() / rel_dir / file_name
|
||||||
|
file_url = f"/uploads/{(rel_dir / file_name).as_posix()}"
|
||||||
|
return storage_path, file_url, file_name
|
||||||
|
|
||||||
|
|
||||||
|
async def save_upload_file(file: UploadFile, *, scene: AdminUploadSceneEnum, resource_type: AdminUploadResourceTypeEnum, admin_id: str) -> StoredAdminUploadFile:
|
||||||
|
original_file_name = validate_file_name(file.filename, resource_type)
|
||||||
|
max_bytes = _max_bytes(resource_type)
|
||||||
|
fd, temp_path = tempfile.mkstemp(prefix="admin_upload_", suffix=".tmp")
|
||||||
|
total = 0
|
||||||
|
final_path: Path | None = None
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "wb") as out:
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(CHUNK_SIZE)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
total += len(chunk)
|
||||||
|
if total > max_bytes:
|
||||||
|
raise HTTPException(status_code=400, detail=f"文件大小不能超过 {max_bytes // 1024 // 1024}MB")
|
||||||
|
out.write(chunk)
|
||||||
|
final_path, file_url, file_name = build_target(scene, resource_type, admin_id, original_file_name)
|
||||||
|
final_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.move(temp_path, final_path)
|
||||||
|
temp_path = ""
|
||||||
|
return StoredAdminUploadFile(
|
||||||
|
storage_path=str(final_path),
|
||||||
|
file_url=file_url,
|
||||||
|
file_name=file_name,
|
||||||
|
original_file_name=original_file_name,
|
||||||
|
file_size_bytes=total,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
if temp_path:
|
||||||
|
try:
|
||||||
|
os.remove(temp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if final_path and final_path.exists():
|
||||||
|
try:
|
||||||
|
final_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
@@ -66,6 +66,7 @@ async def calc_video_credits(
|
|||||||
resolution: str,
|
resolution: str,
|
||||||
engine_id: str | None = None,
|
engine_id: str | None = None,
|
||||||
input_video_duration: float | None = None,
|
input_video_duration: float | None = None,
|
||||||
|
input_image_count: int | None = None,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""Calculate video credits using CreditRatio table, with fallback to hardcoded.
|
"""Calculate video credits using CreditRatio table, with fallback to hardcoded.
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ async def calc_video_credits(
|
|||||||
3. 原硬编码默认算法。
|
3. 原硬编码默认算法。
|
||||||
|
|
||||||
input_video_duration: 用户上传的参考视频总时长(秒),不为空时额外计费
|
input_video_duration: 用户上传的参考视频总时长(秒),不为空时额外计费
|
||||||
|
input_image_count: 用户上传的参考图片数量,不为空时额外计费
|
||||||
"""
|
"""
|
||||||
if not engine_id:
|
if not engine_id:
|
||||||
video_engines_result = await db.execute(
|
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_base_credits + ratio.input_video_per_second_credits * input_video_duration
|
||||||
) * ratio.input_video_ratio
|
) * ratio.input_video_ratio
|
||||||
base_cost += input_video_cost
|
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)
|
return round(base_cost, 2)
|
||||||
|
|
||||||
base = 60.0
|
base = 60.0
|
||||||
@@ -105,6 +112,8 @@ async def calc_video_credits(
|
|||||||
total = (base + duration_cost) * multiplier
|
total = (base + duration_cost) * multiplier
|
||||||
if input_video_duration and input_video_duration > 0:
|
if input_video_duration and input_video_duration > 0:
|
||||||
total += input_video_duration * 0.5 * multiplier
|
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)
|
return round(total, 2)
|
||||||
|
|
||||||
|
|
||||||
@@ -120,6 +129,7 @@ async def calc_image_credits(
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
image_size: str,
|
image_size: str,
|
||||||
engine_id: str | None = None,
|
engine_id: str | None = None,
|
||||||
|
input_image_count: int | None = None,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""Calculate image credits using CreditRatio table, with fallback to hardcoded.
|
"""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 精确规则;
|
1. gen_type=image + engine_id + image_size 精确规则;
|
||||||
2. gen_type=image + image_size 下 base_credits/per_second_credits 最高规则;
|
2. gen_type=image + image_size 下 base_credits/per_second_credits 最高规则;
|
||||||
3. 原硬编码默认算法。
|
3. 原硬编码默认算法。
|
||||||
|
|
||||||
|
input_image_count: 用户上传的参考图片数量,不为空时额外计费
|
||||||
"""
|
"""
|
||||||
# 如果engine_id为空,默认查询权重最高的图片引擎积分规则
|
# 如果engine_id为空,默认查询权重最高的图片引擎积分规则
|
||||||
if not engine_id:
|
if not engine_id:
|
||||||
@@ -144,12 +156,21 @@ async def calc_image_credits(
|
|||||||
engine_id=engine_id,
|
engine_id=engine_id,
|
||||||
)
|
)
|
||||||
if ratio:
|
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
|
# Fallback
|
||||||
multiplier = {"4K": 2.0, "2K": 1.0}.get(image_size, 1.0)
|
multiplier = {"4K": 2.0, "2K": 1.0}.get(image_size, 1.0)
|
||||||
base_cost = 4.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(
|
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.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
||||||
from app.schemas.home_material import (
|
from app.schemas.home_material import (
|
||||||
HomeMaterialAssetOut,
|
HomeMaterialAssetOut,
|
||||||
|
HomeMaterialMediaReference,
|
||||||
HomeMaterialCategoryOut,
|
HomeMaterialCategoryOut,
|
||||||
HomeMaterialPublicAssetOut,
|
HomeMaterialPublicAssetOut,
|
||||||
HomeMaterialPublicCategoryGroupOut,
|
HomeMaterialPublicCategoryGroupOut,
|
||||||
@@ -39,6 +40,24 @@ def _load_json(value: str | None) -> dict[str, Any] | None:
|
|||||||
return 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:
|
class HomeMaterialQueryService:
|
||||||
"""首页素材高性能查询组装层:列表查询 → ID 去重 → 批量查询 → map 组装。"""
|
"""首页素材高性能查询组装层:列表查询 → ID 去重 → 批量查询 → map 组装。"""
|
||||||
|
|
||||||
@@ -152,6 +171,8 @@ class HomeMaterialQueryService:
|
|||||||
watermark_id=asset.watermark_id,
|
watermark_id=asset.watermark_id,
|
||||||
watermark_name=watermark.name if watermark else None,
|
watermark_name=watermark.name if watermark else None,
|
||||||
watermark_config=_load_json(asset.watermark_config_json),
|
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,
|
width=asset.width,
|
||||||
height=asset.height,
|
height=asset.height,
|
||||||
duration_seconds=asset.duration_seconds,
|
duration_seconds=asset.duration_seconds,
|
||||||
@@ -340,6 +361,8 @@ class HomeMaterialQueryService:
|
|||||||
width=a.width,
|
width=a.width,
|
||||||
height=a.height,
|
height=a.height,
|
||||||
duration_seconds=a.duration_seconds,
|
duration_seconds=a.duration_seconds,
|
||||||
|
generation_prompt=a.generation_prompt,
|
||||||
|
media_references=_load_media_references(a.media_references_json),
|
||||||
sort_order=a.sort_order,
|
sort_order=a.sort_order,
|
||||||
)
|
)
|
||||||
for a in assets
|
for a in assets
|
||||||
@@ -403,6 +426,8 @@ class HomeMaterialQueryService:
|
|||||||
width=asset.width,
|
width=asset.width,
|
||||||
height=asset.height,
|
height=asset.height,
|
||||||
duration_seconds=asset.duration_seconds,
|
duration_seconds=asset.duration_seconds,
|
||||||
|
generation_prompt=asset.generation_prompt,
|
||||||
|
media_references=_load_media_references(asset.media_references_json),
|
||||||
sort_order=asset.sort_order,
|
sort_order=asset.sort_order,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,14 +12,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.enums.home_material import (
|
from app.enums.home_material import (
|
||||||
HOME_MATERIAL_DEFAULT_CONFIG,
|
HOME_MATERIAL_DEFAULT_CONFIG,
|
||||||
|
HOME_MATERIAL_GENERATION_PROMPT_MAX_LEN,
|
||||||
|
HOME_MATERIAL_MEDIA_REFERENCES_JSON_MAX_LEN,
|
||||||
|
HOME_MATERIAL_MEDIA_REFERENCES_MAX_COUNT,
|
||||||
HomeMaterialAssetStatus,
|
HomeMaterialAssetStatus,
|
||||||
HomeMaterialConfigKeyEnum,
|
HomeMaterialConfigKeyEnum,
|
||||||
|
HomeMaterialLogEventEnum,
|
||||||
HomeMaterialMediaType,
|
HomeMaterialMediaType,
|
||||||
HomeMaterialPublicResponseMode,
|
HomeMaterialPublicResponseMode,
|
||||||
HomeMaterialWatermarkPosition,
|
HomeMaterialWatermarkPosition,
|
||||||
HomeMaterialWatermarkSizeMode,
|
HomeMaterialWatermarkSizeMode,
|
||||||
HomeMaterialWatermarkType,
|
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.base import async_session
|
||||||
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
||||||
from app.models.system_config import SystemConfig
|
from app.models.system_config import SystemConfig
|
||||||
@@ -36,6 +42,7 @@ from app.schemas.home_material import (
|
|||||||
HomeMaterialConfigUpdate,
|
HomeMaterialConfigUpdate,
|
||||||
HomeMaterialPublicCategoryListOut,
|
HomeMaterialPublicCategoryListOut,
|
||||||
HomeMaterialPublicFlatOut,
|
HomeMaterialPublicFlatOut,
|
||||||
|
HomeMaterialMediaReference,
|
||||||
HomeMaterialPublicGroupedOut,
|
HomeMaterialPublicGroupedOut,
|
||||||
HomeMaterialRegenerateWatermarkRequest,
|
HomeMaterialRegenerateWatermarkRequest,
|
||||||
HomeMaterialTextWatermarkPreviewRequest,
|
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.query import query_service
|
||||||
from app.services.home_material.storage import storage_service
|
from app.services.home_material.storage import storage_service
|
||||||
from app.services.home_material.watermark_processor import watermark_processor
|
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
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
|
|
||||||
@@ -66,11 +75,99 @@ def _json_loads(value: str | None) -> dict[str, Any] | None:
|
|||||||
return 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:
|
def _clean_title(value: str | None) -> str | None:
|
||||||
value = (value or "").strip()
|
value = (value or "").strip()
|
||||||
return value or None
|
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]:
|
def _normalize_watermark_config_dict(value: dict[str, Any] | None, fallback_watermark_id: str | None = None) -> dict[str, Any]:
|
||||||
"""兼容旧水印配置。旧数据没有 watermark_type 时按 image 处理。"""
|
"""兼容旧水印配置。旧数据没有 watermark_type 时按 image 处理。"""
|
||||||
data = dict(value or {})
|
data = dict(value or {})
|
||||||
@@ -108,6 +205,8 @@ def _asset_snapshot(asset: HomeMaterialAsset | None) -> dict[str, Any] | None:
|
|||||||
"cover_url": asset.cover_url,
|
"cover_url": asset.cover_url,
|
||||||
"watermark_id": asset.watermark_id,
|
"watermark_id": asset.watermark_id,
|
||||||
"watermark_config": _json_loads(asset.watermark_config_json),
|
"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,
|
"is_active": asset.is_active,
|
||||||
"sort_order": asset.sort_order,
|
"sort_order": asset.sort_order,
|
||||||
"deleted_at": asset.deleted_at,
|
"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:
|
async def _flush_refresh(db: AsyncSession, obj: Any) -> None:
|
||||||
"""
|
"""
|
||||||
写入后立即返回 ORM 对象前必须显式刷新。
|
写入后立即返回 ORM 对象前必须显式刷新。
|
||||||
@@ -372,10 +496,24 @@ class HomeMaterialService:
|
|||||||
watermark_config: HomeMaterialWatermarkConfig,
|
watermark_config: HomeMaterialWatermarkConfig,
|
||||||
is_active: bool,
|
is_active: bool,
|
||||||
sort_order: int,
|
sort_order: int,
|
||||||
|
generation_prompt: str | None = None,
|
||||||
|
media_references_json: str | None = None,
|
||||||
admin_id: str | None,
|
admin_id: str | None,
|
||||||
) -> tuple[HomeMaterialUploadResultOut, dict[str, Any]]:
|
) -> tuple[HomeMaterialUploadResultOut, dict[str, Any]]:
|
||||||
await self._get_category(db, category_id, active_only=False)
|
await self._get_category(db, category_id, active_only=False)
|
||||||
clean_title = _clean_title(title)
|
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
|
watermark_type = watermark_config.watermark_type
|
||||||
final_watermark_id: str | None = None
|
final_watermark_id: str | None = None
|
||||||
if watermark_type == HomeMaterialWatermarkType.IMAGE:
|
if watermark_type == HomeMaterialWatermarkType.IMAGE:
|
||||||
@@ -415,6 +553,8 @@ class HomeMaterialService:
|
|||||||
original_storage_path=stored.storage_path,
|
original_storage_path=stored.storage_path,
|
||||||
watermark_id=final_watermark_id,
|
watermark_id=final_watermark_id,
|
||||||
watermark_config_json=_json_dumps(cfg.model_dump(mode="json")),
|
watermark_config_json=_json_dumps(cfg.model_dump(mode="json")),
|
||||||
|
generation_prompt=clean_prompt,
|
||||||
|
media_references_json=refs_json,
|
||||||
status=HomeMaterialAssetStatus.PROCESSING.value,
|
status=HomeMaterialAssetStatus.PROCESSING.value,
|
||||||
width=probe.width,
|
width=probe.width,
|
||||||
height=probe.height,
|
height=probe.height,
|
||||||
@@ -427,7 +567,10 @@ class HomeMaterialService:
|
|||||||
)
|
)
|
||||||
db.add(asset)
|
db.add(asset)
|
||||||
await _flush_refresh(db, 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(
|
async def list_assets(
|
||||||
self,
|
self,
|
||||||
@@ -466,15 +609,67 @@ class HomeMaterialService:
|
|||||||
asset = await self._get_asset(db, asset_id)
|
asset = await self._get_asset(db, asset_id)
|
||||||
await self._get_category(db, req.category_id)
|
await self._get_category(db, req.category_id)
|
||||||
before = _asset_snapshot(asset) or {}
|
before = _asset_snapshot(asset) or {}
|
||||||
|
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.category_id = req.category_id
|
||||||
asset.title = req.title
|
asset.title = req.title
|
||||||
asset.is_active = req.is_active
|
asset.is_active = req.is_active
|
||||||
asset.sort_order = req.sort_order
|
asset.sort_order = req.sort_order
|
||||||
|
asset.generation_prompt = clean_prompt
|
||||||
|
asset.media_references_json = refs_json
|
||||||
asset.updated_by = admin_id
|
asset.updated_by = admin_id
|
||||||
db.add(asset)
|
db.add(asset)
|
||||||
await _flush_refresh(db, 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 = _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
|
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(
|
async def prepare_regenerate(
|
||||||
self,
|
self,
|
||||||
@@ -509,6 +704,28 @@ class HomeMaterialService:
|
|||||||
db.add(asset)
|
db.add(asset)
|
||||||
return asset, before
|
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:
|
async def get_asset_status(self, db: AsyncSession, asset_id: str) -> HomeMaterialAssetStatusOut:
|
||||||
asset = await self._get_asset(db, asset_id)
|
asset = await self._get_asset(db, asset_id)
|
||||||
return HomeMaterialAssetStatusOut(
|
return HomeMaterialAssetStatusOut(
|
||||||
@@ -541,6 +758,8 @@ class HomeMaterialService:
|
|||||||
watermarked_url=asset.watermarked_url,
|
watermarked_url=asset.watermarked_url,
|
||||||
cover_url=asset.cover_url,
|
cover_url=asset.cover_url,
|
||||||
watermark_config=_json_loads(asset.watermark_config_json),
|
watermark_config=_json_loads(asset.watermark_config_json),
|
||||||
|
generation_prompt=asset.generation_prompt,
|
||||||
|
media_references=_media_references_out(asset.media_references_json),
|
||||||
message=message,
|
message=message,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import Any
|
|||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -31,11 +31,22 @@ from app.enums.private_portrait import (
|
|||||||
PrivatePortraitRemoteDeleteStatus,
|
PrivatePortraitRemoteDeleteStatus,
|
||||||
PrivatePortraitValidateSessionStatus,
|
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.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.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, PrivatePortraitSelectableAssetOut, PrivatePortraitValidateSessionOut
|
||||||
from app.services.operation_log_service import log_operation_error, log_operation_event
|
from app.services.operation_log_service import log_operation_error, log_operation_event
|
||||||
from app.services.private_portrait.ark_client import ArkPrivateAssetClient
|
from app.services.private_portrait.ark_client import ArkPrivateAssetClient
|
||||||
from app.services.private_portrait.project_service import get_user_project, refresh_project_counters
|
from app.services.private_portrait.project_service import get_user_project, refresh_project_counters
|
||||||
|
from app.services.private_portrait.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 (
|
from app.services.private_portrait.quota_service import (
|
||||||
count_user_counting_assets,
|
count_user_counting_assets,
|
||||||
ensure_private_portrait_asset_quota_available,
|
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} 秒")
|
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:
|
def validate_session_to_out(session: PrivatePortraitValidateSession, *, include_user: bool = False) -> PrivatePortraitValidateSessionOut:
|
||||||
return PrivatePortraitValidateSessionOut(
|
return PrivatePortraitValidateSessionOut(
|
||||||
id=session.id,
|
id=session.id,
|
||||||
@@ -417,11 +500,14 @@ async def create_asset(
|
|||||||
library_type: str | None = None,
|
library_type: str | None = None,
|
||||||
) -> PrivatePortraitAsset:
|
) -> PrivatePortraitAsset:
|
||||||
_assert_enabled_asset_type(payload.asset_type)
|
_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)
|
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
|
||||||
if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
|
if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
|
||||||
raise HTTPException(status_code=400, detail="项目未激活,不能上传素材")
|
raise HTTPException(status_code=400, detail="项目未激活,不能上传素材")
|
||||||
|
|
||||||
|
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)
|
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)
|
group = await get_project_active_group(db, user_id=user_id, project_id=project.id, library_type=project.library_type)
|
||||||
public_url = _public_url(payload.url)
|
public_url = _public_url(payload.url)
|
||||||
@@ -445,7 +531,7 @@ async def create_asset(
|
|||||||
)
|
)
|
||||||
db.add(asset)
|
db.add(asset)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"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:
|
try:
|
||||||
remote_resp = await ArkPrivateAssetClient().create_asset(project_name=project.remote_project_name, group_id=group.remote_group_id, url=public_url, asset_type=payload.asset_type, name=payload.name)
|
remote_resp = await ArkPrivateAssetClient().create_asset(project_name=project.remote_project_name, group_id=group.remote_group_id, url=public_url, asset_type=payload.asset_type, name=payload.name)
|
||||||
remote_asset_id = remote_resp.get("Id") or remote_resp.get("AssetId") or remote_resp.get("assetId")
|
remote_asset_id = remote_resp.get("Id") or remote_resp.get("AssetId") or remote_resp.get("assetId")
|
||||||
@@ -456,6 +542,28 @@ async def create_asset(
|
|||||||
asset.status = PrivatePortraitAssetStatus.PROCESSING.value
|
asset.status = PrivatePortraitAssetStatus.PROCESSING.value
|
||||||
asset.next_poll_at = now + timedelta(seconds=_poll_interval_seconds(asset.asset_type))
|
asset.next_poll_at = now + timedelta(seconds=_poll_interval_seconds(asset.asset_type))
|
||||||
asset.raw_response_json = _json(remote_resp)
|
asset.raw_response_json = _json(remote_resp)
|
||||||
|
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 refresh_project_counters(db, [project.id])
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(asset)
|
await db.refresh(asset)
|
||||||
@@ -465,7 +573,7 @@ async def create_asset(
|
|||||||
asset.status = PrivatePortraitAssetStatus.FAILED.value
|
asset.status = PrivatePortraitAssetStatus.FAILED.value
|
||||||
asset.error_message = _exception_message(exc)
|
asset.error_message = _exception_message(exc)
|
||||||
await db.flush()
|
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
|
raise
|
||||||
|
|
||||||
|
|
||||||
@@ -598,10 +706,19 @@ async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str, li
|
|||||||
asset.deleted_at = now
|
asset.deleted_at = now
|
||||||
asset.status = PrivatePortraitAssetStatus.LOCAL_DELETED.value
|
asset.status = PrivatePortraitAssetStatus.LOCAL_DELETED.value
|
||||||
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.PENDING.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 refresh_project_counters(db, [asset.project_id])
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(asset)
|
await db.refresh(asset)
|
||||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_LOCAL.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "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
|
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")
|
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
|
return
|
||||||
now = datetime.now(timezone.utc)
|
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:
|
try:
|
||||||
await ArkPrivateAssetClient(for_celery=True).delete_asset(project_name=asset.remote_project_name, asset_id=asset.remote_asset_id)
|
await ArkPrivateAssetClient(for_celery=True).delete_asset(project_name=asset.remote_project_name, asset_id=asset.remote_asset_id)
|
||||||
asset.status = PrivatePortraitAssetStatus.REMOTE_DELETED.value
|
asset.status = PrivatePortraitAssetStatus.REMOTE_DELETED.value
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ from app.enums.private_portrait import (
|
|||||||
PrivatePortraitProjectStatus,
|
PrivatePortraitProjectStatus,
|
||||||
PrivatePortraitRemoteDeleteStatus,
|
PrivatePortraitRemoteDeleteStatus,
|
||||||
)
|
)
|
||||||
|
from app.enums.upload_resource import UploadResourceSourceModelEnum
|
||||||
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject
|
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject
|
||||||
from app.schemas.private_portrait import PrivatePortraitProjectCreate, PrivatePortraitProjectOut, PrivatePortraitProjectUpdate
|
from app.schemas.private_portrait import PrivatePortraitProjectCreate, PrivatePortraitProjectOut, PrivatePortraitProjectUpdate
|
||||||
from app.services.operation_log_service import log_operation_event
|
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
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
DOMAIN = "private_portrait"
|
DOMAIN = "private_portrait"
|
||||||
@@ -264,8 +267,29 @@ async def soft_delete_project(
|
|||||||
) -> PrivatePortraitProject:
|
) -> PrivatePortraitProject:
|
||||||
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
|
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
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.deleted_at = now
|
||||||
project.status = PrivatePortraitProjectStatus.DELETED.value
|
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(
|
await db.execute(
|
||||||
update(PrivatePortraitAsset)
|
update(PrivatePortraitAsset)
|
||||||
.where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.deleted_at.is_(None))
|
.where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.deleted_at.is_(None))
|
||||||
@@ -285,6 +309,6 @@ async def soft_delete_project(
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
message="本地软删私域人像素材项目",
|
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
|
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
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
@@ -29,6 +30,7 @@ from app.schemas.shot_replicate import (
|
|||||||
ShotSegmentDeleteOut,
|
ShotSegmentDeleteOut,
|
||||||
ShotSegmentDetailOut,
|
ShotSegmentDetailOut,
|
||||||
ShotSegmentListOut,
|
ShotSegmentListOut,
|
||||||
|
ShotSegmentSplitRetryOut,
|
||||||
ShotReanalyzeOut,
|
ShotReanalyzeOut,
|
||||||
ShotSegmentOut,
|
ShotSegmentOut,
|
||||||
ShotSplitByAIOut,
|
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(
|
async def create_custom_segment(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
|
|
||||||
from sqlalchemy import select
|
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_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_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>[^/]+)$")
|
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$")
|
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>[^/]+)$")
|
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"}
|
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".svg"}
|
||||||
VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv"}
|
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:
|
except ValueError:
|
||||||
return None
|
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/",)
|
ignored_prefixes = ("home_materials/",)
|
||||||
if rel in {"site_logo.png"} or rel.startswith(ignored_prefixes) or rel.startswith("pdf_"):
|
if rel in {"site_logo.png"} or rel.startswith(ignored_prefixes) or rel.startswith("pdf_"):
|
||||||
return ParsedUploadPath(
|
return ParsedUploadPath(
|
||||||
|
|||||||
-506
File diff suppressed because one or more lines are too long
+506
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</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">
|
<link rel="stylesheet" crossorigin href="/assets/index-JhRVnnL-.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -165,6 +165,43 @@ export async function uploadVideo(file: File, durationSeconds?: number): Promise
|
|||||||
return await res.json();
|
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> {
|
export async function uploadHotOpeningImage(file: File): Promise<UploadResourceResult> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', file);
|
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> {
|
export async function removefour(projectId: string, stepId: string ,params: any): Promise<any> {
|
||||||
return api.post(`/shot-replications/projects/${projectId}/steps/${stepId}/generate-video`, params);
|
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 {
|
export interface GetAreaParams {
|
||||||
level?: string;
|
level?: string;
|
||||||
@@ -797,7 +854,7 @@ export async function getHomeCaseHeader(): Promise<any> {
|
|||||||
return api.get(`/home-materials/categories`);
|
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`);
|
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> {
|
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}`);
|
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`, {
|
return api.post<PrivatePortraitAsset>(`/private-portrait/projects/${projectId}/assets`, {
|
||||||
url: payload.url,
|
url: payload.url,
|
||||||
asset_type: payload.assetType || 'Image',
|
asset_type: payload.assetType || 'Image',
|
||||||
@@ -946,6 +1003,7 @@ export async function createPrivatePortraitAsset(projectId: string, payload: { u
|
|||||||
video_cover_url: payload.videoCoverUrl || null,
|
video_cover_url: payload.videoCoverUrl || null,
|
||||||
file_size: payload.fileSize ?? null,
|
file_size: payload.fileSize ?? null,
|
||||||
mime_type: payload.mimeType || 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}`);
|
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`, {
|
return api.post<PrivatePortraitAsset>(`/private-portrait/virtual-projects/${projectId}/assets`, {
|
||||||
url: payload.url,
|
url: payload.url,
|
||||||
asset_type: payload.assetType || 'Image',
|
asset_type: payload.assetType || 'Image',
|
||||||
@@ -1016,6 +1074,7 @@ export async function createPrivatePortraitVirtualAsset(projectId: string, paylo
|
|||||||
video_cover_url: payload.videoCoverUrl || null,
|
video_cover_url: payload.videoCoverUrl || null,
|
||||||
file_size: payload.fileSize ?? null,
|
file_size: payload.fileSize ?? null,
|
||||||
mime_type: payload.mimeType || 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',
|
justifyContent: 'space-between',
|
||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: isOver ? '#ffffffff' : '#000000ff',
|
color: isOver ? '#000000ff' : '#000000ff',
|
||||||
padding: '0 8px',
|
padding: '0 8px',
|
||||||
|
|
||||||
}}>
|
}}>
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<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)}%
|
{rawPercent.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
{data.enabled ? (
|
{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}
|
{used.toFixed(2)} / {total.toFixed(2)} {unit}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,61 +1,79 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
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 type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset, UploadResourceHistoryItem } from '../types';
|
||||||
|
|
||||||
import PrivatePortraitAssetPicker from './privatePortrait/picker/AssetPicker';
|
import PrivatePortraitAssetPicker from './privatePortrait/picker/AssetPicker';
|
||||||
import UploadResourceHistoryPicker from './uploadResource/UploadResourceHistoryPicker';
|
import UploadResourceHistoryPicker from './uploadResource/UploadResourceHistoryPicker';
|
||||||
|
|
||||||
interface UploadSelectorProps {
|
interface UploadSelectorProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
accept?: string;
|
accept?: string;
|
||||||
|
multiple?: boolean;
|
||||||
onLocalSelect?: (files: File[]) => void;
|
onLocalSelect?: (files: File[]) => void;
|
||||||
|
|
||||||
onHistorySelect?: (items: UploadResourceHistoryItem[]) => void;
|
onHistorySelect?: (items: UploadResourceHistoryItem[]) => void;
|
||||||
/** 兼容旧页面:由 UploadSelector 内部打开素材选择器,确认后回传素材数组。 */
|
/** 兼容旧页面:由 UploadSelector 内部打开素材选择器,确认后回传素材数组。 */
|
||||||
|
|
||||||
onPortraitSelect?: (items: PrivatePortraitSelectableAsset[]) => void;
|
onPortraitSelect?: (items: PrivatePortraitSelectableAsset[]) => void;
|
||||||
/** 新页面推荐:只选择素材库类型,父组件自行打开统一选择器。 */
|
|
||||||
onPortraitLibrarySelect?: (libraryType: PrivatePortraitLibraryType) => void;
|
onPortraitLibrarySelect?: (libraryType: PrivatePortraitLibraryType) => void;
|
||||||
uploading?: boolean;
|
uploading?: boolean;
|
||||||
tooltipTitle?: string;
|
tooltipTitle?: string;
|
||||||
|
maxImageCount?: number;
|
||||||
|
maxVideoCount?: number;
|
||||||
|
usedImageCount?: number;
|
||||||
|
usedVideoCount?: number;
|
||||||
|
usedVideoDuration?: number;
|
||||||
|
maxVideoDuration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UploadSelector: React.FC<UploadSelectorProps> = ({
|
const UploadSelector: React.FC<UploadSelectorProps> = ({
|
||||||
children,
|
children,
|
||||||
accept = 'image/*,video/*',
|
accept = 'image/*,video/*',
|
||||||
|
multiple = true,
|
||||||
onLocalSelect,
|
onLocalSelect,
|
||||||
onHistorySelect,
|
onHistorySelect,
|
||||||
onPortraitSelect,
|
onPortraitSelect,
|
||||||
onPortraitLibrarySelect,
|
onPortraitLibrarySelect,
|
||||||
uploading,
|
uploading,
|
||||||
tooltipTitle,
|
tooltipTitle,
|
||||||
|
maxImageCount,
|
||||||
|
maxVideoCount,
|
||||||
|
usedImageCount,
|
||||||
|
usedVideoCount,
|
||||||
|
usedVideoDuration,
|
||||||
|
maxVideoDuration,
|
||||||
}) => {
|
}) => {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
|
||||||
const [historyModalVisible, setHistoryModalVisible] = useState(false);
|
|
||||||
const [portraitPickerOpen, setPortraitPickerOpen] = useState(false);
|
const [portraitPickerOpen, setPortraitPickerOpen] = useState(false);
|
||||||
const [portraitLibraryType, setPortraitLibraryType] = useState<PrivatePortraitLibraryType>('real_person');
|
const [portraitLibraryType, setPortraitLibraryType] = useState<PrivatePortraitLibraryType>('real_person');
|
||||||
|
const [historyModalVisible, setHistoryModalVisible] = useState(false);
|
||||||
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
|
|
||||||
const handleLocalSelect = () => {
|
const handleLocalSelect = () => {
|
||||||
|
setPopoverOpen(false);
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
if (files && onLocalSelect) {
|
if (files && onLocalSelect) {
|
||||||
onLocalSelect(Array.from(files));
|
const fileList = Array.from(files);
|
||||||
|
onLocalSelect(fileList);
|
||||||
}
|
}
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = '';
|
fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
if (!uploading) {
|
|
||||||
setModalVisible(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openPortraitPicker = (libraryType: PrivatePortraitLibraryType) => {
|
const openPortraitPicker = (libraryType: PrivatePortraitLibraryType) => {
|
||||||
setModalVisible(false);
|
setPopoverOpen(false);
|
||||||
|
if (onPortraitSelect) {
|
||||||
|
setPortraitLibraryType(libraryType);
|
||||||
|
setPortraitPickerOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (onPortraitLibrarySelect) {
|
if (onPortraitLibrarySelect) {
|
||||||
onPortraitLibrarySelect(libraryType);
|
onPortraitLibrarySelect(libraryType);
|
||||||
return;
|
return;
|
||||||
@@ -64,42 +82,101 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
|||||||
setPortraitPickerOpen(true);
|
setPortraitPickerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = [
|
const handleHistorySelect = () => {
|
||||||
{
|
setPopoverOpen(false);
|
||||||
key: 'history',
|
|
||||||
label: '历史记录',
|
|
||||||
icon: <HistoryOutlined style={{ fontSize: 20, color: '#6366f1' }} />,
|
|
||||||
description: '从历史上传记录中选择',
|
|
||||||
onClick: () => {
|
|
||||||
setModalVisible(false);
|
|
||||||
setHistoryModalVisible(true);
|
setHistoryModalVisible(true);
|
||||||
},
|
};
|
||||||
},
|
|
||||||
{
|
const content = (
|
||||||
key: 'real_person',
|
<div style={{ padding: '4px 0', minWidth: 180 }}>
|
||||||
label: '真人素材',
|
<div
|
||||||
icon: <UserOutlined style={{ fontSize: 20, color: '#ec4899' }} />,
|
onClick={() => {
|
||||||
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();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -107,93 +184,44 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
|||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept={accept}
|
accept={accept}
|
||||||
multiple
|
multiple={multiple}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
/>
|
/>
|
||||||
{tooltipTitle ? (
|
{tooltipTitle ? (
|
||||||
<Tooltip title={tooltipTitle}>
|
<Tooltip title={tooltipTitle}>
|
||||||
<div onClick={handleClick} style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
|
<Popover
|
||||||
|
content={content}
|
||||||
|
open={popoverOpen}
|
||||||
|
onOpenChange={setPopoverOpen}
|
||||||
|
trigger="click"
|
||||||
|
placement="bottomLeft"
|
||||||
|
>
|
||||||
|
<div style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
</Popover>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<div onClick={handleClick} style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
|
<Popover
|
||||||
|
content={content}
|
||||||
|
open={popoverOpen}
|
||||||
|
onOpenChange={setPopoverOpen}
|
||||||
|
trigger="click"
|
||||||
|
placement="bottomLeft"
|
||||||
|
>
|
||||||
|
<div style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</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
|
<UploadResourceHistoryPicker
|
||||||
open={historyModalVisible}
|
open={historyModalVisible}
|
||||||
onClose={() => setHistoryModalVisible(false)}
|
onClose={() => setHistoryModalVisible(false)}
|
||||||
onSelect={(items) => {
|
onSelect={(items) => {
|
||||||
onHistorySelect?.(items);
|
onHistorySelect?.(items);
|
||||||
setHistoryModalVisible(false);
|
setHistoryModalVisible(false);
|
||||||
setModalVisible(false);
|
|
||||||
}}
|
}}
|
||||||
allowedTypes={accept.includes('audio') ? ['image', 'video', 'audio'] : accept.includes('video') ? ['image', 'video'] : ['image']}
|
allowedTypes={accept.includes('audio') ? ['image', 'video', 'audio'] : accept.includes('video') ? ['image', 'video'] : ['image']}
|
||||||
/>
|
/>
|
||||||
@@ -206,6 +234,14 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
|||||||
onPortraitSelect?.(assets);
|
onPortraitSelect?.(assets);
|
||||||
setPortraitPickerOpen(false);
|
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 React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Button, Empty, Popconfirm, Space, Tag, Tooltip } from 'antd';
|
import { Button, Card, Empty, Modal, Popconfirm, Space, Tag, Tooltip } from 'antd';
|
||||||
import { DeleteOutlined, PictureOutlined, ReloadOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, EyeOutlined, PictureOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||||
import type { PrivatePortraitAsset } from '../../../types';
|
import type { PrivatePortraitAsset } from '../../../types';
|
||||||
|
|
||||||
const statusColor: Record<string, string> = {
|
const statusColor: Record<string, string> = {
|
||||||
@@ -12,11 +12,20 @@ const statusColor: Record<string, string> = {
|
|||||||
delete_failed: 'red',
|
delete_failed: 'red',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const statusText: Record<string, string> = {
|
||||||
|
Active: '入库成功',
|
||||||
|
Processing: '入库处理中',
|
||||||
|
Failed: '入库失败',
|
||||||
|
local_deleted: '本地已删除',
|
||||||
|
remote_deleted: '远程已删除',
|
||||||
|
delete_failed: '删除失败',
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: PrivatePortraitAsset[];
|
items: PrivatePortraitAsset[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onSync: (assetId: string) => void;
|
|
||||||
onDelete: (assetId: string) => void;
|
onDelete: (assetId: string) => void;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildPreviewUrl = (url?: string | null) => {
|
const buildPreviewUrl = (url?: string | null) => {
|
||||||
@@ -39,53 +48,114 @@ const formatDuration = (value?: number | null) => {
|
|||||||
return `${duration.toFixed(duration >= 10 ? 0 : 1)}s`;
|
return `${duration.toFixed(duration >= 10 ? 0 : 1)}s`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PrivatePortraitAssetGrid: React.FC<Props> = ({ items, onSync, onDelete }) => {
|
const PrivatePortraitAssetGrid: React.FC<Props> = ({ items, onDelete, onRefresh }) => {
|
||||||
if (!items.length) return <Empty description="暂无真人素材" />;
|
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 (
|
return (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 14 }}>
|
<>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 14 }}>
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const isVideo = item.assetType === 'Video';
|
const isVideo = item.assetType === 'Video';
|
||||||
const previewUrl = getAssetPreviewUrl(item);
|
const previewUrl = getAssetPreviewUrl(item);
|
||||||
return (
|
return (
|
||||||
<div key={item.id} style={{ border: '1px solid #e2e8f0', borderRadius: 12, overflow: 'hidden', background: '#fff' }}>
|
<Card
|
||||||
<div style={{ height: 150, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
|
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 ? (
|
{previewUrl ? (
|
||||||
isVideo ? (
|
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' }} />
|
<video src={previewUrl} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
) : (
|
) : (
|
||||||
<img src={previewUrl} alt={item.name || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
<img src={previewUrl} alt={item.name || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
)
|
)
|
||||||
) : isVideo ? (
|
) : isVideo ? (
|
||||||
<VideoCameraOutlined style={{ fontSize: 32, color: '#94a3b8' }} />
|
<VideoCameraOutlined style={{ fontSize: 42, color: '#64748b' }} />
|
||||||
) : (
|
) : (
|
||||||
<PictureOutlined style={{ fontSize: 32, color: '#94a3b8' }} />
|
<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 && (
|
{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 }}>
|
<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)}
|
{formatDuration(item.videoDuration)}
|
||||||
</div>
|
</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>
|
<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>
|
</Tooltip>
|
||||||
<Space wrap size={4} style={{ marginTop: 8 }}>
|
<Space wrap size={4}>
|
||||||
<Tag color={statusColor[item.status] || 'default'}>{item.status}</Tag>
|
<Tag color={statusColor[item.status] || 'default'}>{statusText[item.status] || item.status}</Tag>
|
||||||
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
|
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
{item.errorMessage && <div style={{ color: '#ef4444', fontSize: 12, marginTop: 6 }}>{item.errorMessage}</div>}
|
<Space size={6} wrap>
|
||||||
<Space style={{ marginTop: 10 }} size={6}>
|
|
||||||
<Button size="small" icon={<ReloadOutlined />} onClick={() => onSync(item.id)}>刷新</Button>
|
|
||||||
<Popconfirm title="确认删除这个素材吗?" onConfirm={() => onDelete(item.id)}>
|
<Popconfirm title="确认删除这个素材吗?" onConfirm={() => onDelete(item.id)}>
|
||||||
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</Space>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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 { Button, Input, Modal, Space, Upload, message } from 'antd';
|
||||||
import { UploadOutlined } from '@ant-design/icons';
|
import { UploadOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd/es/upload/interface';
|
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 MIN_PRIVATE_VIDEO_DURATION = 2;
|
||||||
const MAX_PRIVATE_VIDEO_DURATION = 15;
|
const MAX_PRIVATE_VIDEO_DURATION = 15;
|
||||||
@@ -89,14 +89,15 @@ const PrivatePortraitAssetUpload: React.FC<Props> = ({ projectId, open, onClose,
|
|||||||
return;
|
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, {
|
await createPrivatePortraitAsset(projectId, {
|
||||||
url: uploaded.url,
|
url: uploaded.url,
|
||||||
assetType,
|
assetType,
|
||||||
name: name.trim() || file.name,
|
name: name.trim() || file.name,
|
||||||
videoDuration,
|
videoDuration: uploaded.duration_seconds ?? videoDuration,
|
||||||
fileSize: file.size,
|
fileSize: uploaded.file_size_bytes ?? file.size,
|
||||||
mimeType: file.type || null,
|
mimeType: file.type || null,
|
||||||
|
uploadResourceId: uploaded.resource_id || null,
|
||||||
});
|
});
|
||||||
message.success(assetType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中');
|
message.success(assetType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中');
|
||||||
reset();
|
reset();
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState, useEffect } from 'react';
|
||||||
import { Tabs, Typography } from 'antd';
|
import { Card, Col, Row, Tabs, Typography, message } from 'antd';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { getPrivatePortraitConfig, getPrivatePortraitProjects, getPrivatePortraitVirtualConfig, getPrivatePortraitVirtualProjects } from '../../../api';
|
||||||
|
import type { PrivatePortraitConfig, PrivatePortraitProject } from '../../../types';
|
||||||
import RealPersonLibraryPanel from './RealPersonLibraryPanel';
|
import RealPersonLibraryPanel from './RealPersonLibraryPanel';
|
||||||
import VirtualMaterialPanel from './VirtualMaterialPanel';
|
import VirtualMaterialPanel from './VirtualMaterialPanel';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
type PrivatePortraitTabKey = 'real_person' | 'aigc_virtual';
|
type PrivatePortraitTabKey = 'real_person' | 'aigc_virtual';
|
||||||
|
|
||||||
@@ -15,6 +17,48 @@ const normalizeTabKey = (value?: string | null): PrivatePortraitTabKey => (
|
|||||||
const PrivatePortraitLibraryPanel: React.FC = () => {
|
const PrivatePortraitLibraryPanel: React.FC = () => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [activeKey, setActiveKey] = useState<PrivatePortraitTabKey>(() => normalizeTabKey(searchParams.get('portraitTab')));
|
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(() => [
|
const items = useMemo(() => [
|
||||||
{
|
{
|
||||||
@@ -45,6 +89,33 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
|
|||||||
<Title level={4} style={{ margin: 0 }}>私域素材库</Title>
|
<Title level={4} style={{ margin: 0 }}>私域素材库</Title>
|
||||||
<Text type="secondary">统一管理真人素材和虚拟素材。真人项目组需先完成人脸认证,虚拟项目组会同步创建火山 AIGC Asset Group。</Text>
|
<Text type="secondary">统一管理真人素材和虚拟素材。真人项目组需先完成人脸认证,虚拟项目组会同步创建火山 AIGC Asset Group。</Text>
|
||||||
</div>
|
</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
|
<Tabs
|
||||||
activeKey={activeKey}
|
activeKey={activeKey}
|
||||||
onChange={handleTabChange}
|
onChange={handleTabChange}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Button, Card, Popconfirm, Space, Tag, Typography, message } from 'antd';
|
import { Button, Card, Empty, Input, Pagination, Popconfirm, Select, Space, Spin, Typography, message } from 'antd';
|
||||||
import { DeleteOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types';
|
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types';
|
||||||
import { deletePrivatePortraitAsset, deletePrivatePortraitProject, getPrivatePortraitAssets, syncPrivatePortraitAsset } from '../../../api';
|
import { deletePrivatePortraitAsset, deletePrivatePortraitProject, getPrivatePortraitAssets } from '../../../api';
|
||||||
import PrivatePortraitAssetGrid from './AssetGrid';
|
import PrivatePortraitAssetGrid from './AssetGrid';
|
||||||
import PrivatePortraitAssetUpload from './AssetUpload';
|
import PrivatePortraitAssetUpload from './AssetUpload';
|
||||||
|
|
||||||
@@ -16,12 +16,27 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
|
|||||||
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
|
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [uploadOpen, setUploadOpen] = 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);
|
setLoading(true);
|
||||||
try {
|
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);
|
setAssets(res.items);
|
||||||
|
setAssetTotal(res.total || 0);
|
||||||
|
setAssetPage(page);
|
||||||
|
setAssetPageSize(pageSize);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || '加载素材失败');
|
message.error(e?.message || '加载素材失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -29,23 +44,12 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { loadAssets(); }, [project.id]);
|
useEffect(() => { loadAssets(1, assetPageSize); }, [project.id, keyword, assetStatus, assetType]);
|
||||||
|
|
||||||
const handleSync = async (assetId: string) => {
|
|
||||||
try {
|
|
||||||
await syncPrivatePortraitAsset(assetId);
|
|
||||||
await loadAssets();
|
|
||||||
onChanged();
|
|
||||||
message.success('素材状态已刷新');
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.message || '刷新失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteAsset = async (assetId: string) => {
|
const handleDeleteAsset = async (assetId: string) => {
|
||||||
try {
|
try {
|
||||||
await deletePrivatePortraitAsset(assetId);
|
await deletePrivatePortraitAsset(assetId);
|
||||||
await loadAssets();
|
await loadAssets(assetPage, assetPageSize);
|
||||||
onChanged();
|
onChanged();
|
||||||
message.success('素材已删除');
|
message.success('素材已删除');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -67,26 +71,76 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title={<Space><span>{project.name}</span><Tag color={canUpload ? 'green' : 'processing'}>{project.status}</Tag></Space>}
|
title={<Space><span>{project.name}</span></Space>}
|
||||||
extra={(
|
extra={(
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" icon={<UploadOutlined />} disabled={!canUpload} onClick={() => setUploadOpen(true)}>上传素材</Button>
|
<Button type="primary" icon={<UploadOutlined />} disabled={!canUpload} onClick={() => setUploadOpen(true)}>上传素材</Button>
|
||||||
<Button icon={<ReloadOutlined />} onClick={loadAssets} loading={loading}>刷新</Button>
|
|
||||||
<Popconfirm title="确认删除这个真人素材项目组吗?" onConfirm={handleDeleteProject}>
|
<Popconfirm title="确认删除这个真人素材项目组吗?" onConfirm={handleDeleteProject}>
|
||||||
<Button danger icon={<DeleteOutlined />}>删除项目组</Button>
|
<Button danger icon={<DeleteOutlined />}>删除项目组</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
style={{ borderRadius: 12 }}
|
style={{ borderRadius: 16 }}
|
||||||
>
|
>
|
||||||
<Typography.Paragraph style={{ color: '#64748b' }}>{project.description || '暂无描述'}</Typography.Paragraph>
|
|
||||||
{!canUpload && (
|
{!canUpload && (
|
||||||
<Typography.Paragraph style={{ color: '#f97316' }}>
|
<Typography.Paragraph style={{ color: '#f97316' }}>
|
||||||
项目组未完成真人认证,暂不能上传素材。请重新创建项目组并完成手机扫码认证。
|
项目组未完成真人认证,暂不能上传素材。请重新创建项目组并完成手机扫码认证。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
)}
|
)}
|
||||||
<PrivatePortraitAssetGrid items={assets} loading={loading} onSync={handleSync} onDelete={handleDeleteAsset} />
|
<Space style={{ width: '100%', marginBottom: 16 }} wrap>
|
||||||
<PrivatePortraitAssetUpload projectId={project.id} open={uploadOpen} onClose={() => setUploadOpen(false)} onSuccess={() => { loadAssets(); onChanged(); }} />
|
<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>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button, Empty, List, Tag } from 'antd';
|
import { Empty, Space, Tag, Typography } from 'antd';
|
||||||
import type { PrivatePortraitProject } from '../../../types';
|
import type { PrivatePortraitProject } from '../../../types';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: PrivatePortraitProject[];
|
items: PrivatePortraitProject[];
|
||||||
selectedId?: string | null;
|
selectedId?: string | null;
|
||||||
@@ -11,24 +13,40 @@ interface Props {
|
|||||||
const PrivatePortraitProjectList: React.FC<Props> = ({ items, selectedId, onSelect }) => {
|
const PrivatePortraitProjectList: React.FC<Props> = ({ items, selectedId, onSelect }) => {
|
||||||
if (!items.length) return <Empty description="暂无项目组" />;
|
if (!items.length) return <Empty description="暂无项目组" />;
|
||||||
return (
|
return (
|
||||||
<List
|
<Space direction="vertical" style={{ width: '100%' }} size={10}>
|
||||||
dataSource={items}
|
{items.map((project) => {
|
||||||
renderItem={(item) => (
|
const active = selectedId === project.id;
|
||||||
<List.Item style={{ padding: 0, marginBottom: 8 }}>
|
return (
|
||||||
<Button
|
<div
|
||||||
block
|
key={project.id}
|
||||||
onClick={() => onSelect(item)}
|
onClick={() => onSelect(project)}
|
||||||
style={{ height: 'auto', padding: 12, textAlign: 'left', borderColor: selectedId === item.id ? '#8b5cf6' : '#e2e8f0' }}
|
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 }}>
|
<Space style={{ width: '100%', justifyContent: 'space-between' }} align="start">
|
||||||
<strong>{item.name}</strong>
|
<div style={{ minWidth: 0 }}>
|
||||||
<Tag color={item.activeAssetCount > 0 ? 'green' : 'default'}>{item.activeAssetCount}/{item.assetCount}</Tag>
|
<Text strong ellipsis style={{ display: 'block' }}>{project.name}</Text>
|
||||||
|
{project.description && <Text type="secondary" ellipsis style={{ display: 'block', fontSize: 12 }}>{project.description}</Text>}
|
||||||
</div>
|
</div>
|
||||||
{item.description && <div style={{ color: '#64748b', fontSize: 12, marginTop: 4 }}>{item.description}</div>}
|
{/* <Tag color={project.status === 'active' ? 'green' : 'processing'}>
|
||||||
</Button>
|
{project.status === 'active' ? '可用' : project.status}
|
||||||
</List.Item>
|
</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 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 { CheckCircleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
import type { PrivatePortraitProject, PrivatePortraitValidateSession } from '../../../types';
|
import type { PrivatePortraitProject, PrivatePortraitValidateSession } from '../../../types';
|
||||||
import { createPrivatePortraitProject, getPrivatePortraitProjects, getPrivatePortraitValidateSession } from '../../../api';
|
import { createPrivatePortraitProject, getPrivatePortraitProjects, getPrivatePortraitValidateSession } from '../../../api';
|
||||||
@@ -117,27 +117,36 @@ const RealPersonLibraryPanel: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<div>
|
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>真人素材库</Typography.Title>
|
<Typography.Title level={4} style={{ margin: 0 }}>真人素材库</Typography.Title>
|
||||||
<Typography.Text type="secondary">创建项目组时先完成真人认证,认证成功后项目组才会正式创建并可上传素材。</Typography.Text>
|
<Typography.Text type="secondary">创建项目组时先完成真人认证,认证成功后项目组才会正式创建并可上传素材。</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
<Space>
|
|
||||||
<Button icon={<ReloadOutlined />} onClick={loadProjects} loading={loading}>刷新</Button>
|
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>新建项目组</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col xs={24} md={7} lg={6}>
|
<Col xs={24} lg={7}>
|
||||||
<Card title="项目组" style={{ borderRadius: 12 }}>
|
<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} />
|
<PrivatePortraitProjectList items={projects} selectedId={selected?.id} onSelect={setSelected} />
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} md={17} lg={18}>
|
<Col xs={24} lg={17}>
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<PrivatePortraitProjectDetail project={selected} onDeleted={() => { setSelected(null); loadProjects(); }} onChanged={loadProjects} />
|
<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>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -178,7 +187,7 @@ const RealPersonLibraryPanel: React.FC = () => {
|
|||||||
{isSuccess ? '认证成功,项目组正在刷新' : '请使用手机扫码完成人脸认证,成功后回到电脑端查看项目组。'}
|
{isSuccess ? '认证成功,项目组正在刷新' : '请使用手机扫码完成人脸认证,成功后回到电脑端查看项目组。'}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</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>
|
</Space>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
App,
|
App,
|
||||||
Button,
|
Button,
|
||||||
@@ -21,12 +21,10 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { UploadFile } from 'antd/es/upload/interface';
|
import type { UploadFile } from 'antd/es/upload/interface';
|
||||||
import {
|
import {
|
||||||
CloudSyncOutlined,
|
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
PictureOutlined,
|
PictureOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
ReloadOutlined,
|
|
||||||
UploadOutlined,
|
UploadOutlined,
|
||||||
VideoCameraOutlined,
|
VideoCameraOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
@@ -36,15 +34,13 @@ import {
|
|||||||
deletePrivatePortraitVirtualAsset,
|
deletePrivatePortraitVirtualAsset,
|
||||||
deletePrivatePortraitVirtualProject,
|
deletePrivatePortraitVirtualProject,
|
||||||
getPrivatePortraitVirtualAssets,
|
getPrivatePortraitVirtualAssets,
|
||||||
getPrivatePortraitVirtualConfig,
|
|
||||||
getPrivatePortraitVirtualProjects,
|
getPrivatePortraitVirtualProjects,
|
||||||
syncPrivatePortraitVirtualAsset,
|
uploadPrivatePortraitVirtualImage,
|
||||||
uploadImage,
|
uploadPrivatePortraitVirtualVideo,
|
||||||
uploadVideo,
|
|
||||||
} from '../../../api';
|
} 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;
|
type AssetTypeFilter = 'Image' | 'Video' | undefined;
|
||||||
|
|
||||||
@@ -53,39 +49,18 @@ const MAX_PRIVATE_VIDEO_DURATION = 15;
|
|||||||
|
|
||||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||||
creating: { label: '本地创建中', color: 'processing' },
|
creating: { label: '本地创建中', color: 'processing' },
|
||||||
Processing: { label: '火山处理中', color: 'processing' },
|
Processing: { label: '入库处理中', color: 'processing' },
|
||||||
Active: { label: '可用于生成', color: 'success' },
|
Active: { label: '入库成功', color: 'success' },
|
||||||
Failed: { label: '入库失败', color: 'error' },
|
Failed: { label: '入库失败', color: 'error' },
|
||||||
local_deleted: { label: '本地已删', color: 'default' },
|
local_deleted: { label: '本地已删', color: 'default' },
|
||||||
remote_deleted: { label: '远端已删', color: 'default' },
|
remote_deleted: { label: '远端已删', color: 'default' },
|
||||||
delete_failed: { label: '远端删除失败', color: 'error' },
|
delete_failed: { label: '远端删除失败', color: 'error' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const assetTypeConfig: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
|
const formatDuration = (value?: number | null) => {
|
||||||
Image: { label: '图片', color: 'green', icon: <PictureOutlined /> },
|
const duration = Number(value || 0);
|
||||||
Video: { label: '视频', color: 'blue', icon: <VideoCameraOutlined /> },
|
if (!Number.isFinite(duration) || duration <= 0) return '-';
|
||||||
};
|
return `${duration.toFixed(duration >= 10 ? 0 : 1)}s`;
|
||||||
|
|
||||||
const formatDateTime = (dateStr?: string | null) => {
|
|
||||||
if (!dateStr) return '-';
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
if (Number.isNaN(date.getTime())) return '-';
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatSize = (size?: number | null) => {
|
|
||||||
const value = Number(size || 0);
|
|
||||||
if (!value) return '-';
|
|
||||||
if (value >= 1024 * 1024 * 1024) return `${(value / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
||||||
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(2)} MB`;
|
|
||||||
if (value >= 1024) return `${(value / 1024).toFixed(2)} KB`;
|
|
||||||
return `${value} B`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildPreviewUrl = (url?: string | null) => {
|
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>;
|
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 VirtualMaterialPanel: React.FC = () => {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const [config, setConfig] = useState<PrivatePortraitConfig | null>(null);
|
|
||||||
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string>();
|
const [selectedProjectId, setSelectedProjectId] = useState<string>();
|
||||||
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
|
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
|
||||||
@@ -184,26 +152,13 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
const [previewUrl, setPreviewUrl] = useState('');
|
const [previewUrl, setPreviewUrl] = useState('');
|
||||||
const [previewType, setPreviewType] = useState<'Image' | 'Video'>('Image');
|
const [previewType, setPreviewType] = useState<'Image' | 'Video'>('Image');
|
||||||
const [createForm] = Form.useForm<{ name: string; description?: string }>();
|
const [createForm] = Form.useForm<{ name: string; description?: string }>();
|
||||||
|
const pollingRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const selectedProject = useMemo(
|
const selectedProject = useMemo(
|
||||||
() => projects.find((item) => item.id === selectedProjectId) || null,
|
() => projects.find((item) => item.id === selectedProjectId) || null,
|
||||||
[projects, selectedProjectId],
|
[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 () => {
|
const loadProjects = async () => {
|
||||||
setProjectLoading(true);
|
setProjectLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -245,7 +200,7 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const reloadAll = async () => {
|
const reloadAll = async () => {
|
||||||
await Promise.all([loadConfig(), loadProjects()]);
|
await loadProjects();
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -256,6 +211,32 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
if (selectedProjectId) loadAssets(1, assetPageSize);
|
if (selectedProjectId) loadAssets(1, assetPageSize);
|
||||||
}, [selectedProjectId]);
|
}, [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 handleCreateProject = async () => {
|
||||||
const values = await createForm.validateFields();
|
const values = await createForm.validateFields();
|
||||||
setCreatingProject(true);
|
setCreatingProject(true);
|
||||||
@@ -295,20 +276,21 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
if (currentType === 'Video' && !validatePrivateVideoDuration(duration, message.error)) {
|
if (currentType === 'Video' && !validatePrivateVideoDuration(duration, message.error)) {
|
||||||
return;
|
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, {
|
await createPrivatePortraitVirtualAsset(selectedProjectId, {
|
||||||
url: uploaded.url,
|
url: uploaded.url,
|
||||||
assetType: currentType,
|
assetType: currentType,
|
||||||
name: assetName.trim() || file.name,
|
name: assetName.trim() || file.name,
|
||||||
videoDuration: duration,
|
videoDuration: uploaded.duration_seconds ?? duration,
|
||||||
fileSize: file.size,
|
fileSize: uploaded.file_size_bytes ?? file.size,
|
||||||
mimeType: file.type || null,
|
mimeType: file.type || null,
|
||||||
|
uploadResourceId: uploaded.resource_id || null,
|
||||||
});
|
});
|
||||||
message.success(currentType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中');
|
message.success(currentType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中');
|
||||||
setUploadOpen(false);
|
setUploadOpen(false);
|
||||||
setFileList([]);
|
setFileList([]);
|
||||||
setAssetName('');
|
setAssetName('');
|
||||||
await Promise.all([loadConfig(), loadProjects(), loadAssets(1, assetPageSize)]);
|
await Promise.all([loadProjects(), loadAssets(1, assetPageSize)]);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
message.error(err?.message || '上传素材失败');
|
message.error(err?.message || '上传素材失败');
|
||||||
} finally {
|
} 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) => {
|
const handleDeleteAsset = async (assetId: string) => {
|
||||||
try {
|
try {
|
||||||
await deletePrivatePortraitVirtualAsset(assetId);
|
await deletePrivatePortraitVirtualAsset(assetId);
|
||||||
message.success('素材已删除,远端删除将异步执行');
|
message.success('素材已删除,远端删除将异步执行');
|
||||||
await Promise.all([loadConfig(), loadProjects(), loadAssets(assetPage, assetPageSize)]);
|
await Promise.all([loadProjects(), loadAssets(assetPage, assetPageSize)]);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
message.error(err?.message || '删除素材失败');
|
message.error(err?.message || '删除素材失败');
|
||||||
}
|
}
|
||||||
@@ -370,36 +342,40 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
style={{ borderRadius: 16, overflow: 'hidden', borderColor: '#eef2f7' }}
|
style={{ borderRadius: 16, overflow: 'hidden', borderColor: '#eef2f7' }}
|
||||||
cover={(
|
cover={(
|
||||||
<div style={{ height: 170, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
|
<div style={{ height: 170, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
|
||||||
{preview && !isVideo ? (
|
{preview ? (
|
||||||
<img src={preview} alt={asset.name || '虚拟人像素材'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
isVideo && asset.videoCoverUrl ? (
|
||||||
) : preview && isVideo && asset.videoCoverUrl ? (
|
|
||||||
<img src={preview} alt={asset.name || '视频封面'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
<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 ? (
|
) : isVideo ? (
|
||||||
<VideoCameraOutlined style={{ fontSize: 42, color: '#64748b' }} />
|
<VideoCameraOutlined style={{ fontSize: 42, color: '#64748b' }} />
|
||||||
) : (
|
) : (
|
||||||
<PictureOutlined style={{ fontSize: 42, color: '#64748b' }} />
|
<PictureOutlined style={{ fontSize: 42, color: '#64748b' }} />
|
||||||
)}
|
)}
|
||||||
{isVideo && <Tag color="blue" style={{ position: 'absolute', left: 10, top: 10 }}>视频</Tag>}
|
{isVideo && <Tag color="blue" style={{ position: 'absolute', left: 10, top: 10 }}>视频</Tag>}
|
||||||
|
{preview && (
|
||||||
<Button size="small" shape="circle" icon={<EyeOutlined />} style={{ position: 'absolute', right: 10, top: 10 }} onClick={() => openPreview(asset)} />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||||
<Tooltip title={asset.name || asset.remoteAssetId || asset.id}>
|
<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>
|
</Tooltip>
|
||||||
<Space wrap size={4}>
|
<Space wrap size={4}>
|
||||||
<TypeTag type={asset.assetType} />
|
|
||||||
<StatusTag status={asset.status} />
|
<StatusTag status={asset.status} />
|
||||||
|
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
|
||||||
</Space>
|
</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>
|
<Space size={6} wrap>
|
||||||
<Button size="small" icon={<CloudSyncOutlined />} onClick={() => handleSyncAsset(asset.id)}>同步</Button>
|
|
||||||
<Popconfirm title="确认删除这个虚拟人像素材吗?" onConfirm={() => handleDeleteAsset(asset.id)}>
|
<Popconfirm title="确认删除这个虚拟人像素材吗?" onConfirm={() => handleDeleteAsset(asset.id)}>
|
||||||
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
@@ -411,30 +387,6 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col xs={24} lg={7}>
|
<Col xs={24} lg={7}>
|
||||||
<Card
|
<Card
|
||||||
@@ -466,13 +418,13 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
<Text strong ellipsis style={{ display: 'block' }}>{project.name}</Text>
|
<Text strong ellipsis style={{ display: 'block' }}>{project.name}</Text>
|
||||||
{project.description && <Text type="secondary" ellipsis style={{ display: 'block', fontSize: 12 }}>{project.description}</Text>}
|
{project.description && <Text type="secondary" ellipsis style={{ display: 'block', fontSize: 12 }}>{project.description}</Text>}
|
||||||
</div>
|
</div>
|
||||||
<StatusTag status={project.status} />
|
{/* <StatusTag status={project.status} /> */}
|
||||||
</Space>
|
</Space>
|
||||||
<Space wrap size={4} style={{ marginTop: 10 }}>
|
<Space wrap size={4} style={{ marginTop: 10 }}>
|
||||||
<Tag>总 {project.assetCount || 0}</Tag>
|
<Tag>总 {project.assetCount || 0}</Tag>
|
||||||
<Tag color="green">图 {project.imageAssetCount || 0}</Tag>
|
<Tag color="green">图 {project.imageAssetCount || 0}</Tag>
|
||||||
<Tag color="blue">视频 {project.videoAssetCount || 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>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -488,7 +440,7 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
title={selectedProject ? selectedProject.name : '素材资产'}
|
title={selectedProject ? selectedProject.name : '素材资产'}
|
||||||
extra={(
|
extra={(
|
||||||
<Space wrap>
|
<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>
|
<Button type="primary" icon={<UploadOutlined />} disabled={!selectedProjectId} onClick={() => setUploadOpen(true)}>上传图片/视频</Button>
|
||||||
{selectedProjectId && (
|
{selectedProjectId && (
|
||||||
<Popconfirm title="确认删除当前虚拟人像项目组吗?" onConfirm={handleDeleteProject}>
|
<Popconfirm title="确认删除当前虚拟人像项目组吗?" onConfirm={handleDeleteProject}>
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ interface PrivatePortraitAssetPickerProps {
|
|||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSelect: (assets: PrivatePortraitSelectableAsset[]) => 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 }> = {
|
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,
|
maxCount = 20,
|
||||||
onClose,
|
onClose,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
maxImageCount,
|
||||||
|
maxVideoCount,
|
||||||
|
usedImageCount,
|
||||||
|
usedVideoCount,
|
||||||
|
usedVideoDuration,
|
||||||
|
maxVideoDuration,
|
||||||
|
accept,
|
||||||
}) => {
|
}) => {
|
||||||
const meta = libraryMeta[libraryType];
|
const meta = libraryMeta[libraryType];
|
||||||
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
||||||
const [projectId, setProjectId] = useState<string | undefined>();
|
const [projectId, setProjectId] = useState<string | undefined>();
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [assetType, setAssetType] = useState<AssetTypeFilter>();
|
const [assetType, setAssetType] = useState<AssetTypeFilter>(accept === 'image/*' ? 'Image' : undefined);
|
||||||
const [assets, setAssets] = useState<PrivatePortraitSelectableAsset[]>([]);
|
const [assets, setAssets] = useState<PrivatePortraitSelectableAsset[]>([]);
|
||||||
const [selectedAssets, setSelectedAssets] = useState<Map<string, PrivatePortraitSelectableAsset>>(new Map());
|
const [selectedAssets, setSelectedAssets] = useState<Map<string, PrivatePortraitSelectableAsset>>(new Map());
|
||||||
const [loadingProjects, setLoadingProjects] = useState(false);
|
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||||
@@ -89,8 +103,8 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
const next = res.items || [];
|
const next = res.items || [];
|
||||||
setProjects(next);
|
setProjects(next);
|
||||||
setProjectId((prev) => (prev && next.some((item) => item.id === prev) ? prev : next[0]?.id));
|
setProjectId((prev) => (prev && next.some((item) => item.id === prev) ? prev : next[0]?.id));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
message.error(err?.message || meta.projectError);
|
message.error((err as { message?: string })?.message || meta.projectError);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingProjects(false);
|
setLoadingProjects(false);
|
||||||
}
|
}
|
||||||
@@ -108,8 +122,8 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
pageSize: 100,
|
pageSize: 100,
|
||||||
});
|
});
|
||||||
setAssets(res.items || []);
|
setAssets(res.items || []);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
message.error(err?.message || meta.assetError);
|
message.error((err as { message?: string })?.message || meta.assetError);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingAssets(false);
|
setLoadingAssets(false);
|
||||||
}
|
}
|
||||||
@@ -117,24 +131,32 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
setTimeout(() => {
|
||||||
setSelectedAssets(new Map());
|
setSelectedAssets(new Map());
|
||||||
setKeyword('');
|
setKeyword('');
|
||||||
setAssetType(undefined);
|
setAssetType(undefined);
|
||||||
setProjectId(undefined);
|
setProjectId(undefined);
|
||||||
setAssets([]);
|
setAssets([]);
|
||||||
loadProjects();
|
loadProjects();
|
||||||
|
}, 0);
|
||||||
}, [open, libraryType]);
|
}, [open, libraryType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
setTimeout(() => {
|
||||||
loadAssets();
|
loadAssets();
|
||||||
}, [open, projectId, assetType]);
|
}, 0);
|
||||||
|
}, [open, projectId, assetType, libraryType]);
|
||||||
|
|
||||||
const toggle = (asset: PrivatePortraitSelectableAsset) => {
|
const toggle = (asset: PrivatePortraitSelectableAsset) => {
|
||||||
if (selectedIds.includes(asset.id)) {
|
if (selectedIds.includes(asset.id)) {
|
||||||
message.info('该素材已添加');
|
message.info('该素材已添加');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (accept === 'image/*' && asset.assetType === 'Video') {
|
||||||
|
message.warning('当前仅支持选择图片素材');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSelectedAssets((prev) => {
|
setSelectedAssets((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
if (next.has(asset.id)) {
|
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 confirm = () => {
|
||||||
const selected = (Array.from(selectedAssets.values()) as PrivatePortraitSelectableAsset[]).filter((item) => !selectedIds.includes(item.id));
|
const selected = (Array.from(selectedAssets.values()) as PrivatePortraitSelectableAsset[]).filter((item) => !selectedIds.includes(item.id));
|
||||||
if (!selected.length) {
|
if (!selected.length) {
|
||||||
@@ -162,20 +198,54 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={meta.title}
|
title={`${meta.title}`}
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
width={920}
|
width={920}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
footer={[
|
footer={[
|
||||||
<Button key="cancel" onClick={onClose}>取消</Button>,
|
<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})
|
添加选中素材({selectedAssets.size})
|
||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 16, minHeight: 480 }}>
|
{accept !== 'image/*' && (
|
||||||
<div style={{ border: '1px solid #eef0f4', borderRadius: 12, padding: 12, background: '#fafafa' }}>
|
<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 }}>
|
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
<Text strong>项目组</Text>
|
<Text strong>项目组</Text>
|
||||||
<Button size="small" icon={<ReloadOutlined />} onClick={loadProjects} loading={loadingProjects} />
|
<Button size="small" icon={<ReloadOutlined />} onClick={loadProjects} loading={loadingProjects} />
|
||||||
@@ -199,9 +269,9 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
<div style={{ width: '100%' }}>
|
<div style={{ width: '100%' }}>
|
||||||
<Text strong ellipsis style={{ display: 'block' }}>{item.name}</Text>
|
<Text strong ellipsis style={{ display: 'block' }}>{item.name}</Text>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
Active {item.activeAssetCount || 0}
|
{/* {item.activeAssetCount || 0} */}
|
||||||
{typeof item.activeImageAssetCount === 'number' || typeof item.activeVideoAssetCount === 'number'
|
{typeof item.activeImageAssetCount === 'number' || typeof item.activeVideoAssetCount === 'number'
|
||||||
? ` · 图 ${item.activeImageAssetCount || 0} / 视 ${item.activeVideoAssetCount || 0}`
|
? ` 图 ${item.activeImageAssetCount || 0} / 视 ${item.activeVideoAssetCount || 0}`
|
||||||
: ''}
|
: ''}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +281,7 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
</Spin>
|
</Spin>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div style={{overflowY: 'auto', height: "100%" }}>
|
||||||
<Space style={{ width: '100%', marginBottom: 12 }}>
|
<Space style={{ width: '100%', marginBottom: 12 }}>
|
||||||
<Input
|
<Input
|
||||||
allowClear
|
allowClear
|
||||||
@@ -222,12 +292,14 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
onPressEnter={loadAssets}
|
onPressEnter={loadAssets}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear={accept !== 'image/*'}
|
||||||
placeholder="素材类型"
|
placeholder="素材类型"
|
||||||
value={assetType}
|
value={assetType}
|
||||||
onChange={setAssetType}
|
onChange={setAssetType}
|
||||||
style={{ width: 116 }}
|
style={{ width: 116 }}
|
||||||
options={[
|
options={accept === 'image/*' ? [
|
||||||
|
{ value: 'Image', label: '图片' },
|
||||||
|
] : [
|
||||||
{ value: 'Image', label: '图片' },
|
{ value: 'Image', label: '图片' },
|
||||||
{ value: 'Video', 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 }} />
|
<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 }}>
|
<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 active = checked.has(asset.id) || selectedIds.includes(asset.id);
|
||||||
const disabled = selectedIds.includes(asset.id);
|
const disabled = selectedIds.includes(asset.id);
|
||||||
const previewUrl = getAssetPreviewUrl(asset);
|
const previewUrl = getAssetPreviewUrl(asset);
|
||||||
@@ -285,7 +357,7 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
<div style={{ padding: 10 }}>
|
<div style={{ padding: 10 }}>
|
||||||
<Text strong ellipsis style={{ display: 'block' }}>{asset.name || '未命名素材'}</Text>
|
<Text strong ellipsis style={{ display: 'block' }}>{asset.name || '未命名素材'}</Text>
|
||||||
<Space wrap size={4} style={{ marginTop: 6 }}>
|
<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 color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
|
||||||
<Tag>{asset.projectName}</Tag>
|
<Tag>{asset.projectName}</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -218,18 +218,18 @@ const UploadResourceHistoryPanel: React.FC = () => {
|
|||||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#1e293b' }}>{group.generatedDate}</div>
|
<div style={{ fontSize: 18, fontWeight: 700, color: '#1e293b' }}>{group.generatedDate}</div>
|
||||||
<Text type="secondary">共 {group.total} 个素材</Text>
|
<Text type="secondary">共 {group.total} 个素材</Text>
|
||||||
</div>
|
</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) => {
|
{group.items.map((item) => {
|
||||||
const checked = selectedIds.has(item.id);
|
const checked = selectedIds.has(item.id);
|
||||||
return (
|
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' }}>
|
<div style={{ position: 'relative', background: '#f1f5f9' }}>
|
||||||
{renderMedia(item)}
|
{renderMedia(item)}
|
||||||
<Checkbox checked={checked} onChange={() => toggle(item.id)} style={{ position: 'absolute', top: 10, left: 10, background: '#fff', borderRadius: 6, padding: 4 }} />
|
<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>
|
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ position: 'absolute', top: 10, right: 10, margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: 12 }}>
|
<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>
|
{/* <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 }}>
|
<Space size={4} wrap style={{ marginTop: 8 }}>
|
||||||
<Tag style={{ margin: 0 }}>{item.moduleLabel}</Tag>
|
<Tag style={{ margin: 0 }}>{item.moduleLabel}</Tag>
|
||||||
<Tag style={{ margin: 0 }}>{bytesText(item.fileSizeBytes)}</Tag>
|
<Tag style={{ margin: 0 }}>{bytesText(item.fileSizeBytes)}</Tag>
|
||||||
@@ -246,7 +246,7 @@ const UploadResourceHistoryPanel: React.FC = () => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{group.items.length < group.total && (
|
{group.items.length < group.total && (
|
||||||
<div style={{ textAlign: 'center', marginTop: 14 }}>
|
<div style={{ textAlign: 'left', marginTop: 14 }}>
|
||||||
<Button onClick={() => handleLoadMoreDay(group)}>加载更多</Button>
|
<Button onClick={() => handleLoadMoreDay(group)}>加载更多</Button>
|
||||||
</div>
|
</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); }} />
|
<Pagination current={page} pageSize={pageSize} total={totalDays} showSizeChanger pageSizeOptions={[5, 10]} onChange={(nextPage, nextSize) => { setPage(nextPage); setPageSize(nextSize); }} />
|
||||||
</div>
|
</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}
|
{previewItem ? renderMedia(previewItem, 'preview') : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -177,7 +177,8 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
|
|||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<Empty description="暂无可复用的历史上传素材" style={{ padding: '48px 0' }} />
|
<Empty description="暂无可复用的历史上传素材" style={{ padding: '48px 0' }} />
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14, minHeight: 260 }}>
|
<div style={{ height: '500px', overflowY: 'auto' }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14 }}>
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const checked = checkedMap.has(item.id);
|
const checked = checkedMap.has(item.id);
|
||||||
const disabled = selectedIdSet.has(item.id);
|
const disabled = selectedIdSet.has(item.id);
|
||||||
@@ -228,10 +229,7 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16, paddingBottom: 8 }}>
|
||||||
</Spin>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
|
|
||||||
<Pagination
|
<Pagination
|
||||||
current={page}
|
current={page}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
@@ -244,6 +242,9 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
</Modal>
|
</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={{ 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' }}>
|
<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 ? (
|
{isChecking && countdown < 10 ? (
|
||||||
<Spin size="large" tip="加载中" style={{ color: '#fff' }} />
|
<Spin size="large" description="加载中" style={{ color: '#fff' }} />
|
||||||
) : (
|
) : (
|
||||||
<ClockCircleOutlined style={{ fontSize: 40, color: '#fff' }} />
|
<ClockCircleOutlined style={{ fontSize: 40, color: '#fff' }} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -281,14 +281,42 @@ const GeneratePage: React.FC = () => {
|
|||||||
const videoCount = references.filter(
|
const videoCount = references.filter(
|
||||||
(r) => r.type === "video",
|
(r) => r.type === "video",
|
||||||
).length;
|
).length;
|
||||||
if (isImage && imageCount >= 10) {
|
const videoDuration = references
|
||||||
message.error("最多上传10张图片");
|
.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;
|
return false;
|
||||||
}
|
}
|
||||||
if (isVideo && videoCount >= 3) {
|
if (isVideo && videoCount >= MAX_VIDEOS) {
|
||||||
message.error("最多上传3个视频");
|
message.error(`最多上传${MAX_VIDEOS}个视频`);
|
||||||
return false;
|
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);
|
setUploading(true);
|
||||||
const uploadFn = isImage ? uploadImage : uploadVideo;
|
const uploadFn = isImage ? uploadImage : uploadVideo;
|
||||||
try {
|
try {
|
||||||
@@ -303,6 +331,7 @@ const GeneratePage: React.FC = () => {
|
|||||||
url: res.url,
|
url: res.url,
|
||||||
type: isImage ? "image" : "video",
|
type: isImage ? "image" : "video",
|
||||||
name: `${typeLabel}${typeCount}`,
|
name: `${typeLabel}${typeCount}`,
|
||||||
|
duration: isVideo ? fileDuration : undefined,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
message.success(`${typeLabel}上传成功`);
|
message.success(`${typeLabel}上传成功`);
|
||||||
@@ -1829,11 +1858,11 @@ const GeneratePage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onHistorySelect={(items) => {
|
onHistorySelect={(items) => {
|
||||||
items.forEach((item: any) => {
|
items.forEach((item) => {
|
||||||
setReferences(prev => [...prev, {
|
setReferences(prev => [...prev, {
|
||||||
url: '',
|
url: item.resourceUrl || item.previewUrl || item.displayUrl || '',
|
||||||
type: item.type,
|
type: item.resourceType,
|
||||||
name: item.name,
|
name: item.fileName || '',
|
||||||
}]);
|
}]);
|
||||||
});
|
});
|
||||||
message.success(`成功添加${items.length}个历史记录`);
|
message.success(`成功添加${items.length}个历史记录`);
|
||||||
@@ -1842,17 +1871,24 @@ const GeneratePage: React.FC = () => {
|
|||||||
items.forEach((item: any) => {
|
items.forEach((item: any) => {
|
||||||
setReferences(prev => [...prev, {
|
setReferences(prev => [...prev, {
|
||||||
url: item.previewUrl || '',
|
url: item.previewUrl || '',
|
||||||
type: 'image',
|
type: item.assetType === 'Video' ? 'video' : 'image',
|
||||||
name: item.name || '真人素材',
|
name: item.name || '真人素材',
|
||||||
source: 'private_portrait_asset',
|
source: 'private_portrait_asset',
|
||||||
private_asset_id: item.id,
|
private_asset_id: item.id,
|
||||||
label: '',
|
label: '',
|
||||||
|
duration: item.assetType === 'Video' ? (item.videoDuration || 0) : undefined,
|
||||||
}]);
|
}]);
|
||||||
});
|
});
|
||||||
message.success(`已添加 ${items.length} 个真人素材参考`);
|
message.success(`已添加 ${items.length} 个真人素材参考`);
|
||||||
}}
|
}}
|
||||||
uploading={uploading}
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -917,7 +917,7 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
}));
|
}));
|
||||||
}, [filterType, filterMedia]);
|
}, [filterType, filterMedia]);
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: 'calc(100vh - 90px)', background: '#ffffffff', overflowY: 'auto' }} >
|
<div className="content_box" >
|
||||||
{/* 操作栏:筛选 + 推送按钮 */}
|
{/* 操作栏:筛选 + 推送按钮 */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
PictureOutlined,
|
PictureOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
|
HeartOutlined,
|
||||||
|
ShareAltOutlined,
|
||||||
|
StarOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getmedit ,getHomeCaseHeader,getHomeCaseButton} from '../api';
|
import { getmedit ,getHomeCaseHeader,getHomeCaseButton} from '../api';
|
||||||
@@ -43,7 +46,7 @@ const HomePage: React.FC = () => {
|
|||||||
const [caseAssets, setCaseAssets] = useState<any[]>([]);
|
const [caseAssets, setCaseAssets] = useState<any[]>([]);
|
||||||
const [previewAsset, setPreviewAsset] = useState<any>(null);
|
const [previewAsset, setPreviewAsset] = useState<any>(null);
|
||||||
const previewVideoRef = useRef<HTMLVideoElement>(null);
|
const previewVideoRef = useRef<HTMLVideoElement>(null);
|
||||||
const [activeContentTab, setActiveContentTab] = useState<'works' | 'cases'>('works');
|
const [activeContentTab, setActiveContentTab] = useState<'works' | 'cases'>('cases');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getHomeCaseHeader().then((res: any) => {
|
getHomeCaseHeader().then((res: any) => {
|
||||||
@@ -67,7 +70,7 @@ const HomePage: React.FC = () => {
|
|||||||
const fetchAll = async () => {
|
const fetchAll = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
getmedit(5).then((res) => {
|
getmedit(10).then((res) => {
|
||||||
for (let item in res) {
|
for (let item in res) {
|
||||||
res[item].forEach(element => {
|
res[item].forEach(element => {
|
||||||
// 给每条 element 标记它所属的模块(item 是接口返回的 key)
|
// 给每条 element 标记它所属的模块(item 是接口返回的 key)
|
||||||
@@ -479,10 +482,9 @@ const HomePage: React.FC = () => {
|
|||||||
onClick={() => navigate(entry.path)}
|
onClick={() => navigate(entry.path)}
|
||||||
className="project-card"
|
className="project-card"
|
||||||
style={{
|
style={{
|
||||||
flex: '1',
|
flex: '1 1 280px',
|
||||||
minWidth: 260,
|
minWidth: 280,
|
||||||
height: 120,
|
padding: '16px 20px',
|
||||||
padding: '16px',
|
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
border: '1px solid #e2e8f0',
|
border: '1px solid #e2e8f0',
|
||||||
@@ -492,6 +494,7 @@ const HomePage: React.FC = () => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 14,
|
gap: 14,
|
||||||
|
minHeight: 96,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.borderColor = accent.color;
|
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>
|
<span style={{ fontSize: 20, display: 'flex', transition: 'all 0.3s ease' }}>{entry.icon}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<span style={{ fontSize: 15, fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
<span style={{ fontSize: 15, fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1, minWidth: 0 }}>
|
||||||
{entry.title}
|
{entry.title}
|
||||||
</span>
|
</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: 10, color: accent.color,
|
fontSize: 10, color: accent.color,
|
||||||
padding: '2px 7px', borderRadius: 4,
|
padding: '2px 7px', borderRadius: 4,
|
||||||
background: accent.light, fontWeight: 600,
|
background: accent.light, fontWeight: 600,
|
||||||
|
flexShrink: 0,
|
||||||
}}>{accent.tag}</span>
|
}}>{accent.tag}</span>
|
||||||
</div>
|
</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}
|
{entry.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -583,12 +595,13 @@ const HomePage: React.FC = () => {
|
|||||||
{/* 外层Tab切换:近期作品 / 素材案例 */}
|
{/* 外层Tab切换:近期作品 / 素材案例 */}
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
{[
|
{[
|
||||||
{ key: 'works', label: '近期作品' },
|
|
||||||
{ key: 'cases', label: '素材案例' },
|
{ key: 'cases', label: '素材案例' },
|
||||||
|
|
||||||
|
{ key: 'works', label: '近期作品' },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
onClick={() => setActiveContentTab(item.key as 'works' | 'cases')}
|
onClick={() => setActiveContentTab(item.key as 'cases' | 'works')}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 16px',
|
padding: '6px 16px',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
@@ -781,10 +794,10 @@ const HomePage: React.FC = () => {
|
|||||||
</div>
|
</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 ? (
|
{caseAssets.length === 0 ? (
|
||||||
<div style={{
|
<div style={{
|
||||||
gridColumn: '1 / -1',
|
flex: 1,
|
||||||
padding: '60px 0',
|
padding: '60px 0',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: '#94a3b8',
|
color: '#94a3b8',
|
||||||
@@ -802,11 +815,12 @@ const HomePage: React.FC = () => {
|
|||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
background: '#fff',
|
// background: '#0f172a',
|
||||||
border: '1px solid #e2e8f0',
|
flexShrink: 0,
|
||||||
|
width: "18%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ position: 'relative', aspectRatio: '16/9', }}>
|
<div style={{ position: 'relative', aspectRatio: '9/16', }}>
|
||||||
{asset.mediaType === 'video' ? (
|
{asset.mediaType === 'video' ? (
|
||||||
<>
|
<>
|
||||||
<video
|
<video
|
||||||
@@ -821,9 +835,18 @@ const HomePage: React.FC = () => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: '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>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -833,22 +856,43 @@ const HomePage: React.FC = () => {
|
|||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
{/* {asset.mediaType === 'video' && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '10px 12px',
|
position: 'absolute',
|
||||||
background: '#f8fafc',
|
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: '8px 10px' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
color: '#64748b',
|
color: '#94a3b8',
|
||||||
textAlign: 'center',
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
|
marginBottom: 4,
|
||||||
}}>
|
}}>
|
||||||
{asset.title || `素材 ${index + 1}`}
|
{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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -864,60 +908,248 @@ const HomePage: React.FC = () => {
|
|||||||
setPreviewAsset(null);
|
setPreviewAsset(null);
|
||||||
}}
|
}}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={760}
|
width={900}
|
||||||
centered
|
centered
|
||||||
className="preview-modal"
|
className="preview-modal"
|
||||||
bodyStyle={{
|
style={{ borderRadius: 16, overflow: 'hidden' }}
|
||||||
padding: 0,
|
|
||||||
background: '#fff',
|
|
||||||
borderRadius: 16,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* 固定比例容器 16:9 */}
|
<div style={{ display: 'flex', height: 580 }}>
|
||||||
<div style={{
|
{/* 左侧:素材预览 */}
|
||||||
width: '100%',
|
<div style={{ flex: 1, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
||||||
paddingTop: '56.25%',
|
<div style={{ width: 280, aspectRatio: '9/16', borderRadius: 12, overflow: 'hidden', background: '#0f172a' }}>
|
||||||
position: 'relative',
|
|
||||||
// background: '#000',
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}>
|
|
||||||
{previewAsset?.mediaType === 'video' ? (
|
{previewAsset?.mediaType === 'video' ? (
|
||||||
<video
|
<video
|
||||||
ref={previewVideoRef}
|
ref={previewVideoRef}
|
||||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset.url}`}
|
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset.url}`}
|
||||||
controls
|
controls
|
||||||
autoPlay
|
autoPlay
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
webkit-playsinline="true"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset?.url}`}
|
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset?.url}`}
|
||||||
alt={previewAsset?.title}
|
alt={previewAsset?.title}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
{previewAsset?.mediaType === 'video' && (
|
||||||
</div>
|
|
||||||
{/* 底部标题栏 */}
|
|
||||||
{previewAsset?.title && (
|
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '14px 20px',
|
position: 'absolute',
|
||||||
fontSize: 14,
|
top: 32,
|
||||||
color: '#4b5563',
|
right: 32,
|
||||||
fontWeight: 500,
|
fontSize: 11,
|
||||||
borderTop: '1px solid #f1f5f9',
|
color: '#fff',
|
||||||
textAlign: 'center',
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
padding: '3px 8px',
|
||||||
|
borderRadius: 4,
|
||||||
}}>
|
}}>
|
||||||
{previewAsset.title}
|
AI生成
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
{/* 底部操作栏 */}
|
||||||
|
<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>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Table,
|
Table,
|
||||||
Space,
|
Space,
|
||||||
Pagination,
|
Pagination,
|
||||||
|
Popconfirm,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@@ -18,7 +19,8 @@ import {
|
|||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { Header, Content } = Layout;
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@@ -1227,7 +1229,7 @@ const GenerateConver: React.FC = () => {
|
|||||||
dataIndex: 'targetProjectName',
|
dataIndex: 'targetProjectName',
|
||||||
key: 'targetProjectName',
|
key: 'targetProjectName',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 200,
|
width: 100,
|
||||||
render: (text: string) => (
|
render: (text: string) => (
|
||||||
<span style={{ fontSize: 13, color: '#1e293b', fontWeight: 500 }}>
|
<span style={{ fontSize: 13, color: '#1e293b', fontWeight: 500 }}>
|
||||||
{text || '-'}
|
{text || '-'}
|
||||||
@@ -1350,8 +1352,9 @@ const GenerateConver: React.FC = () => {
|
|||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 120,
|
width: 220,
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/initial/${record.id}/initialinfo`)}
|
onClick={() => navigate(`/initial/${record.id}/initialinfo`)}
|
||||||
style={{
|
style={{
|
||||||
@@ -1368,6 +1371,42 @@ const GenerateConver: React.FC = () => {
|
|||||||
>
|
>
|
||||||
查看详情
|
查看详情
|
||||||
</button>
|
</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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { Button, Drawer, Input, Modal, Popconfirm, Spin, Table, Tag, Tooltip, Upload, message } from 'antd';
|
import { Button, Drawer, Input, Modal, Popconfirm, Spin, Table, Tag, Tooltip, Upload, message } from 'antd';
|
||||||
import { ArrowLeftOutlined, PlusOutlined, XOutlined } from '@ant-design/icons';
|
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';
|
import VideoTrimPicker from '../components/VideoTrimPicker';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@@ -132,6 +134,51 @@ function RemoveInfo() {
|
|||||||
}
|
}
|
||||||
}, [creatID]);
|
}, [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 () => {
|
const refreshPageData = useCallback(async () => {
|
||||||
await Promise.all([fetchTaskDetail(), fetchSegments()]);
|
await Promise.all([fetchTaskDetail(), fetchSegments()]);
|
||||||
}, [fetchTaskDetail, fetchSegments]);
|
}, [fetchTaskDetail, fetchSegments]);
|
||||||
@@ -233,6 +280,7 @@ function RemoveInfo() {
|
|||||||
|
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
|
|
||||||
target_project_name: productName.trim(),
|
target_project_name: productName.trim(),
|
||||||
core_content_point: productSellingPoint.trim(),
|
core_content_point: productSellingPoint.trim(),
|
||||||
material_image_url: productImage,
|
material_image_url: productImage,
|
||||||
@@ -564,6 +612,28 @@ function RemoveInfo() {
|
|||||||
{canCreateReplication(record) ? '视频生成' : '待切割完成'}
|
{canCreateReplication(record) ? '视频生成' : '待切割完成'}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -618,7 +688,7 @@ function RemoveInfo() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{taskDetail ? (
|
{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
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -631,21 +701,74 @@ function RemoveInfo() {
|
|||||||
gap: 30,
|
gap: 30,
|
||||||
padding: 24,
|
padding: 24,
|
||||||
border: '1px solid rgba(99, 102, 241, 0.1)',
|
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',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
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)' }}>
|
<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
|
<video
|
||||||
controls
|
|
||||||
src={videoUrl}
|
src={videoUrl}
|
||||||
style={{ width: '100%', height: '100%'}}
|
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>
|
||||||
|
</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 style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', position: 'relative', zIndex: 1 }}>
|
||||||
<div>
|
<div>
|
||||||
@@ -674,6 +797,8 @@ function RemoveInfo() {
|
|||||||
</Tag>
|
</Tag>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : taskDetail.analysisStatus === 'failed' ? (
|
||||||
|
<span style={{ color: '#ef4444', fontSize: 14 }}>分析失败</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex' }}>
|
<div style={{ display: 'flex' }}>
|
||||||
@@ -684,7 +809,9 @@ function RemoveInfo() {
|
|||||||
<span style={{ fontSize: 14, color: '#f59e0b' }}>分析中</span>
|
<span style={{ fontSize: 14, color: '#f59e0b' }}>分析中</span>
|
||||||
</div>
|
</div>
|
||||||
) : taskDetail.analysisStatus === 'completed' ? (
|
) : 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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1026,7 +1153,7 @@ function RemoveInfo() {
|
|||||||
src={previewVideoUrl || ''}
|
src={previewVideoUrl || ''}
|
||||||
style={{ width: '100%', maxHeight: 420, objectFit: 'contain', backgroundColor: '#000' }}
|
style={{ width: '100%', maxHeight: 420, objectFit: 'contain', backgroundColor: '#000' }}
|
||||||
playsInline
|
playsInline
|
||||||
webkit-playsinline
|
webkit-playsinline="true"
|
||||||
autoPlay
|
autoPlay
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
import { useState, useRef, useCallback } from 'react';
|
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 { FileTextOutlined, CloudUploadOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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';
|
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() {
|
export default function VideoFrameExtractor() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -546,6 +565,12 @@ export default function VideoFrameExtractor() {
|
|||||||
<span style={{ fontSize: 14, color: '#1e293b', fontWeight: 500 }}>{text}</span>
|
<span style={{ fontSize: 14, color: '#1e293b', fontWeight: 500 }}>{text}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
render: (status: string) => renderStatus(status),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '创建时间',
|
title: '创建时间',
|
||||||
dataIndex: 'createdAt',
|
dataIndex: 'createdAt',
|
||||||
@@ -567,6 +592,7 @@ export default function VideoFrameExtractor() {
|
|||||||
key: 'action',
|
key: 'action',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (record) => (
|
render: (record) => (
|
||||||
|
<Space>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
onClick={() => navigate(`/removelens/${record.id}/removeinfo`)}
|
onClick={() => navigate(`/removelens/${record.id}/removeinfo`)}
|
||||||
@@ -586,6 +612,39 @@ export default function VideoFrameExtractor() {
|
|||||||
>
|
>
|
||||||
查看详情
|
查看详情
|
||||||
</Button>
|
</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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
Reference in New Issue
Block a user