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

This commit is contained in:
Lrd
2026-07-09 13:48:30 +08:00
71 changed files with 4758 additions and 1655 deletions
+23 -40
View File
@@ -16,6 +16,7 @@ import type {
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene,
} from '../types';
import type {
@@ -251,39 +252,28 @@ export async function deleteUserResourceCapacity(userId: string): Promise<AdminU
return api.delete(`/admin/users/${userId}/resource-capacity`);
}
export async function uploadPdf(file: File, configKey: string): Promise<{ url: string }> {
const formData = new FormData();
formData.append('file', file);
formData.append('config_key', configKey);
const token = localStorage.getItem('auth_token');
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const res = await fetch(`${baseUrl}/api/admin/upload-pdf`, {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: formData,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || '上传失败');
export async function uploadAdminFile(
file: File,
options: { scene: AdminUploadScene; resourceType: AdminUploadResourceType; durationSeconds?: number | null },
): Promise<AdminUploadFileResult> {
const form = new FormData();
form.append('file', file);
form.append('scene', options.scene);
form.append('resource_type', options.resourceType);
if (options.durationSeconds !== undefined && options.durationSeconds !== null) {
form.append('duration_seconds', String(options.durationSeconds));
}
return res.json();
return api.post<AdminUploadFileResult>('/admin/uploads/files', form);
}
export async function uploadPdf(file: File, _configKey?: string): Promise<{ url: string }> {
const res = await uploadAdminFile(file, { scene: 'system_pdf', resourceType: 'pdf' });
return { url: res.url };
}
export async function uploadLogo(file: File): Promise<{ url: string }> {
const formData = new FormData();
formData.append('file', file);
const token = localStorage.getItem('auth_token');
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const res = await fetch(`${baseUrl}/api/admin/upload-logo`, {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: formData,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || '上传失败');
}
return res.json();
const res = await uploadAdminFile(file, { scene: 'system_logo', resourceType: 'image' });
return { url: res.url };
}
function setMaybe(params: URLSearchParams, key: string, value: unknown): void {
@@ -757,17 +747,8 @@ export async function getOAuthList(params: OAuthListParams): Promise<any> {
}
export async function uploadImage(file: File): Promise<{ url: string; filename: string }> {
const form = new FormData();
form.append('file', file);
const token = localStorage.getItem('auth_token');
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-image`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
if (!res.ok) throw new Error('图片上传失败');
const data = await res.json();
return { url: data.url, filename: data.filename };
const res = await uploadAdminFile(file, { scene: 'open_type_thumb', resourceType: 'image' });
return { url: res.url, filename: res.originalFileName || res.fileName };
}
// 自定义表头字段
@@ -900,6 +881,8 @@ export async function uploadHomeMaterialAsset(payload: HomeMaterialUploadAssetPa
form.append('file', payload.file);
form.append('media_type', payload.mediaType);
if (payload.title && payload.title.trim()) form.append('title', payload.title.trim());
if (payload.generationPrompt && payload.generationPrompt.trim()) form.append('generation_prompt', payload.generationPrompt.trim());
if (payload.mediaReferences && payload.mediaReferences.length > 0) form.append('media_references_json', JSON.stringify(payload.mediaReferences));
form.append('watermark_type', payload.watermarkType);
if (payload.watermarkType === 'image') {
+90 -18
View File
@@ -25,6 +25,9 @@ interface CreditRatio {
inputVideoRatio: number;
inputVideoBaseCredits: number;
inputVideoPerSecondCredits: number;
inputImageRatio: number;
inputImageBaseCredits: number;
inputImagePerImageCredits: number;
}
interface CreditRatioFormValues {
@@ -37,6 +40,9 @@ interface CreditRatioFormValues {
inputVideoRatio?: number;
inputVideoBaseCredits?: number;
inputVideoPerSecondCredits?: number;
inputImageRatio?: number;
inputImageBaseCredits?: number;
inputImagePerImageCredits?: number;
}
const DEFAULT_IMAGE_SIZES = ['2K', '4K'];
@@ -136,6 +142,9 @@ const AdminCreditRatios: React.FC = () => {
payload.input_video_base_credits = values.inputVideoBaseCredits ?? 0;
payload.input_video_per_second_credits = values.inputVideoPerSecondCredits ?? 0;
}
payload.input_image_ratio = values.inputImageRatio ?? 1.0;
payload.input_image_base_credits = values.inputImageBaseCredits ?? 0;
payload.input_image_per_image_credits = values.inputImagePerImageCredits ?? 0;
if (modal.ratio) {
await saveCreditRatio({ id: modal.ratio.id, ...payload });
message.success('已更新');
@@ -188,6 +197,9 @@ const AdminCreditRatios: React.FC = () => {
inputVideoRatio: ratio.inputVideoRatio,
inputVideoBaseCredits: ratio.inputVideoBaseCredits,
inputVideoPerSecondCredits: ratio.inputVideoPerSecondCredits,
inputImageRatio: ratio.inputImageRatio,
inputImageBaseCredits: ratio.inputImageBaseCredits,
inputImagePerImageCredits: ratio.inputImagePerImageCredits,
});
} else {
form.resetFields();
@@ -199,6 +211,9 @@ const AdminCreditRatios: React.FC = () => {
inputVideoRatio: 1.0,
inputVideoBaseCredits: 0,
inputVideoPerSecondCredits: 0.5,
inputImageRatio: 1.0,
inputImageBaseCredits: 0,
inputImagePerImageCredits: 0.5,
});
}
};
@@ -238,31 +253,60 @@ const AdminCreditRatios: React.FC = () => {
),
},
{
title: '视频倍率', dataIndex: 'inputVideoRatio', width: 100,
render: (v: number, r: CreditRatio) => (
<Typography.Text>{r.genType === 'image' ? '-' : (
<span style={{ color: v >= 2 ? '#ef4444' : v >= 1.5 ? '#f59e0b' : '#10b981' }}>x{v}</span>
)}</Typography.Text>
),
title: '传入视频',
children: [
{
title: '倍率', dataIndex: 'inputVideoRatio', width: 90,
render: (v: number, r: CreditRatio) => (
<Typography.Text>{r.genType === 'image' ? '-' : (
<span style={{ color: v >= 2 ? '#ef4444' : v >= 1.5 ? '#f59e0b' : '#10b981' }}>x{v}</span>
)}</Typography.Text>
),
},
{
title: '基础积分', dataIndex: 'inputVideoBaseCredits', width: 100,
render: (v: number, r: CreditRatio) => (
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分`}</Typography.Text>
),
},
{
title: '每秒积分', dataIndex: 'inputVideoPerSecondCredits', width: 100,
render: (v: number, r: CreditRatio) => (
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分/秒`}</Typography.Text>
),
},
],
},
{
title: '视频基础积分', dataIndex: 'inputVideoBaseCredits', width: 110,
render: (v: number, r: CreditRatio) => (
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分`}</Typography.Text>
),
title: '传入图片',
children: [
{
title: '倍率', dataIndex: 'inputImageRatio', width: 90,
render: (v: number) => (
<span style={{ color: v >= 2 ? '#ef4444' : v >= 1.5 ? '#f59e0b' : '#10b981' }}>x{v}</span>
),
},
{
title: '基础积分', dataIndex: 'inputImageBaseCredits', width: 100,
render: (v: number) => <Typography.Text>{v} </Typography.Text>,
},
{
title: '每张积分', dataIndex: 'inputImagePerImageCredits', width: 100,
render: (v: number) => <Typography.Text>{v} /</Typography.Text>,
},
],
},
{
title: '视频每秒积分', dataIndex: 'inputVideoPerSecondCredits', width: 110,
render: (v: number, r: CreditRatio) => (
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分/秒`}</Typography.Text>
),
},
{
title: '示例计算(视频15秒,上传视频15秒)', key: 'example', width: 140,
title: '示例计算', key: 'example', width: 160,
render: (_: any, r: CreditRatio) => {
let total: number;
if (r.genType === 'image') {
total = Math.round(r.baseCredits * r.ratio);
if (r.inputImageRatio && r.inputImagePerImageCredits) {
total += Math.round(
(r.inputImageBaseCredits + r.inputImagePerImageCredits * 3) * r.inputImageRatio
);
}
} else {
total = Math.round((r.baseCredits + r.perSecondCredits * 15) * r.ratio);
if (r.inputVideoRatio && r.inputVideoPerSecondCredits) {
@@ -270,6 +314,11 @@ const AdminCreditRatios: React.FC = () => {
(r.inputVideoBaseCredits + r.inputVideoPerSecondCredits * 15) * r.inputVideoRatio
);
}
if (r.inputImageRatio && r.inputImagePerImageCredits) {
total += Math.round(
(r.inputImageBaseCredits + r.inputImagePerImageCredits * 3) * r.inputImageRatio
);
}
}
return <Typography.Text strong style={{ color: '#6366f1' }}>{total} </Typography.Text>;
},
@@ -346,7 +395,7 @@ const AdminCreditRatios: React.FC = () => {
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 1180 }}
scroll={{ x: 1500 }}
/>
</Card>
@@ -421,6 +470,29 @@ const AdminCreditRatios: React.FC = () => {
</div>
</div>
)}
<div style={{
marginTop: 12,
padding: '12px 16px',
backgroundColor: '#f0fff4',
borderRadius: 8,
border: '1px solid #c6f6d5',
}}>
<Typography.Text strong style={{ display: 'block', marginBottom: 8, color: '#059669' }}>
</Typography.Text>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="inputImageRatio" label="倍率" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入图片倍率' }]}>
<InputNumber min={0} max={10} step={0.1} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="inputImageBaseCredits" label="基础积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入图片基础积分' }]}>
<InputNumber min={0} max={500} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="inputImagePerImageCredits" label="每张积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入图片每张积分' }]}>
<InputNumber min={0} max={50} style={{ width: '100%' }} size="large" />
</Form.Item>
</div>
</div>
</Form>
</Modal>
</div>
+41 -19
View File
@@ -41,25 +41,35 @@ function parseSizes(val: unknown): Record<string, Record<string, string>> {
// Default size options with pixel mappings
const SIZE_OPTIONS: Record<string, Record<string, string>> = {
"1K": {
"1:1": "1024x1024",
"4:3": "1152x864",
"3:4": "864x1152",
"16:9": "1312x736",
"9:16": "736x1312",
"3:2": "1248x832",
"2:3": "832x1248",
"21:9": "1568x672",
},
"2K": {
"1:1": "2048×2048",
"4:3": "2304×1728",
"3:4": "1728×2304",
"16:9": "2560×1440",
"9:16": "1600×2848",
"3:2": "2496×1664",
"2:3": "1664×2496",
"21:9": "3024×1296",
"1:1": "2048x2048",
"4:3": "2304x1728",
"3:4": "1728x2304",
"16:9": "2848x1600",
"9:16": "1600x2848",
"3:2": "2496x1664",
"2:3": "1664x2496",
"21:9": "3136x1344",
},
"4K": {
"1:1": "4096×4096",
"4:3": "4608×3456",
"3:4": "3520×4704",
"16:9": "5404×3040",
"9:16": "3040×5504",
"3:2": "4992×3328",
"2:3": "3328×4992",
"21:9": "6197×2656",
"1:1": "4096x4096",
"4:3": "4704x3520",
"3:4": "3520x4704",
"16:9": "5504x3040",
"9:16": "3040x5504",
"3:2": "4992x3328",
"2:3": "3328x4992",
"21:9": "6197x2656",
},
};
@@ -94,7 +104,7 @@ const AdminImageEngines: React.FC = () => {
const values = await form.validateFields();
// Build supportedSizes from form values
const sizes: Record<string, Record<string, string>> = {};
for (const tier of ["2K", "4K"]) {
for (const tier of ["1K", "2K", "4K"]) {
const selected: string[] = values[`size_${tier}`] || [];
if (selected.length > 0) {
sizes[tier] = {};
@@ -147,7 +157,7 @@ const AdminImageEngines: React.FC = () => {
setModal({ open: true, engine: engine || null });
if (engine) {
const sizeFields: Record<string, string[]> = {};
for (const tier of ["2K", "4K"]) {
for (const tier of ["1K", "2K", "4K"]) {
sizeFields[`size_${tier}`] = Object.keys(engine.supportedSizes?.[tier] || {});
}
form.setFieldsValue({
@@ -161,6 +171,7 @@ const AdminImageEngines: React.FC = () => {
supportedModels: ['doubao-seedream-5-0-260128'],
defaultSize: '2K',
maxImageCount: 0,
size_1K: ALL_RATIOS,
size_2K: ALL_RATIOS,
size_4K: ALL_RATIOS,
});
@@ -187,6 +198,16 @@ const AdminImageEngines: React.FC = () => {
</div>
),
},
{
title: '1K 支持比例', key: 'sizes_1k', width: 260,
render: (_: any, r: ImageEngine) => {
const ratios = Object.keys(r.supportedSizes?.["1K"] || {});
if (ratios.length === 0) return <span style={{ color: '#bfbfbf' }}>-</span>;
return <Space size={2} wrap>{ratios.map(ratio => (
<Tag key={ratio} color="green">{ratio} {r.supportedSizes["1K"][ratio]}</Tag>
))}</Space>;
},
},
{
title: '2K 支持比例', key: 'sizes_2k', width: 260,
render: (_: any, r: ImageEngine) => {
@@ -294,7 +315,7 @@ const AdminImageEngines: React.FC = () => {
</Typography.Text>
</div>
{["2K", "4K"].map(tier => (
{["1K", "2K", "4K"].map(tier => (
<div key={tier} style={{
background: '#fafbfc', borderRadius: 10, padding: '12px 16px',
marginBottom: 12, border: '1px solid #f0f0f5',
@@ -318,6 +339,7 @@ const AdminImageEngines: React.FC = () => {
<Form.Item name="defaultSize" label="默认尺寸档位">
<Select size="large" options={[
{ value: '1K', label: '1K' },
{ value: '2K', label: '2K' },
{ value: '4K', label: '4K' },
]} />
+5 -1
View File
@@ -105,7 +105,11 @@ const AdminSettings: React.FC = () => {
const res = await uploadLogo(file);
setConfigs(prev => prev.map(c => c.key === 'site_logo' ? { ...c, value: res.url } : c));
form.setFieldsValue({ site_logo: res.url });
message.success('Logo上传成功');
const config = configs.find(c => c.key === 'site_logo');
if (config) {
await updateSystemConfig(config.id, res.url);
}
message.success('Logo上传成功并已保存');
} catch {
message.error('上传失败');
} finally {
@@ -115,7 +115,7 @@ const AdminVideoEngines: React.FC = () => {
form.resetFields();
form.setFieldsValue({
isActive: true, priority: 0,
maxDuration: 15,
maxDuration: 30,
maxImageCount: 2,
maxVideoCount: 0,
maxAudioCount: 0,
@@ -123,7 +123,7 @@ const AdminVideoEngines: React.FC = () => {
supportsUniversalReference: true,
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
supportedResolutions: ['480p', '720p', '1080p'],
supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30],
});
}
};
@@ -1,22 +1,25 @@
import React, { useEffect, useState } from 'react';
import { Button, Dropdown, Image, Modal, Select, Space, Table, Tag, message } from 'antd';
import { DeleteOutlined, MoreOutlined, ReloadOutlined } from '@ant-design/icons';
import { Button, Dropdown, Image, Input, Modal, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
import { DeleteOutlined, EditOutlined, MoreOutlined, ReloadOutlined } from '@ant-design/icons';
import {
deleteHomeMaterialAsset,
getHomeMaterialAssetStatus,
getHomeMaterialAssets,
regenerateHomeMaterialWatermark,
updateHomeMaterialAsset,
} from '../../api';
import type {
HomeMaterialAsset,
HomeMaterialAssetQueryParams,
HomeMaterialCategory,
HomeMaterialMediaReference,
HomeMaterialWatermark,
HomeMaterialWatermarkConfig,
HomeMaterialTextWatermarkConfig,
} from '../../types';
import { apiUrl } from '../../utils/resourceUrl';
import WatermarkEditor from './WatermarkEditor';
import MediaReferencesEditor from './MediaReferencesEditor';
interface Props {
categories: HomeMaterialCategory[];
@@ -57,6 +60,12 @@ const defaultConfig: HomeMaterialWatermarkConfig = {
textWatermark: defaultTextWatermark,
};
const mediaTypeText: Record<string, string> = {
image: '图片',
video: '视频',
audio: '音频',
};
function normalizeTextConfig(raw: any): HomeMaterialTextWatermarkConfig {
return {
text: raw?.text || defaultTextWatermark.text,
@@ -94,6 +103,43 @@ function isValidConfig(config: HomeMaterialWatermarkConfig): boolean {
return !!config.watermarkId;
}
function shortText(value?: string | null, max = 48): string {
const text = (value || '').trim();
if (!text) return '';
return text.length > max ? `${text.slice(0, max)}...` : text;
}
function renderMediaReferences(refs?: HomeMaterialMediaReference[] | null) {
const list = refs || [];
if (list.length === 0) return <Typography.Text type="secondary"></Typography.Text>;
return (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
{list.map((ref, index) => {
const displayUrl = apiUrl(ref.displayUrl || ref.url);
const previewUrl = apiUrl(ref.previewUrl || ref.displayUrl || ref.url);
return (
<div key={`${ref.url}-${index}`} style={{ border: '1px solid #f0f0f5', borderRadius: 8, padding: 12, display: 'flex', gap: 12 }}>
<div style={{ width: 140, minHeight: 80, flexShrink: 0, background: '#f6f7fb', borderRadius: 8, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{ref.type === 'image' && <Image width={140} height={80} style={{ objectFit: 'cover' }} src={previewUrl} />}
{ref.type === 'video' && <video src={displayUrl} controls style={{ width: 140, height: 80, objectFit: 'cover', background: '#000' }} />}
{ref.type === 'audio' && <audio src={displayUrl} controls style={{ width: 132 }} />}
</div>
<Space direction="vertical" size={6} style={{ flex: 1, minWidth: 0 }}>
<Space wrap>
<Tag>{mediaTypeText[ref.type] || ref.type}</Tag>
{ref.duration !== undefined && ref.duration !== null && <Tag>{ref.duration}s</Tag>}
{ref.role && <Tag>{ref.role}</Tag>}
</Space>
<Typography.Text strong>{ref.name || '未命名附件'}</Typography.Text>
<Typography.Text copyable ellipsis style={{ maxWidth: 620 }}>{ref.url}</Typography.Text>
</Space>
</div>
);
})}
</Space>
);
}
const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloadKey }) => {
const [items, setItems] = useState<HomeMaterialAsset[]>([]);
const [total, setTotal] = useState(0);
@@ -103,6 +149,10 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
const [regen, setRegen] = useState<HomeMaterialAsset | null>(null);
const [regenConfig, setRegenConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
const [regenSubmitting, setRegenSubmitting] = useState(false);
const [editingConfig, setEditingConfig] = useState<HomeMaterialAsset | null>(null);
const [editPrompt, setEditPrompt] = useState('');
const [editRefs, setEditRefs] = useState<HomeMaterialMediaReference[]>([]);
const [editSubmitting, setEditSubmitting] = useState(false);
const load = async () => {
setLoading(true);
@@ -133,6 +183,12 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
setRegenConfig(normalizeConfig(row.watermarkConfig, row.watermarkId));
};
const openGenerationConfig = (row: HomeMaterialAsset) => {
setEditingConfig(row);
setEditPrompt(row.generationPrompt || '');
setEditRefs(row.mediaReferences || []);
};
const getPreviewImageUrl = (row: HomeMaterialAsset) => {
if (row.mediaType === 'image') return apiUrl(row.watermarkedUrl || row.originalUrl);
return apiUrl(row.coverUrl || '');
@@ -155,6 +211,26 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
});
};
const submitGenerationConfig = async () => {
if (!editingConfig || editSubmitting) return;
setEditSubmitting(true);
try {
await updateHomeMaterialAsset(editingConfig.id, {
category_id: editingConfig.categoryId,
title: editingConfig.title || null,
is_active: editingConfig.isActive,
sort_order: editingConfig.sortOrder,
generation_prompt: editPrompt.trim() || null,
media_references: editRefs,
});
message.success('生成配置已保存');
setEditingConfig(null);
await load();
} finally {
setEditSubmitting(false);
}
};
const submitRegenerate = async () => {
if (!regen || regenSubmitting) return;
if (!isValidConfig(regenConfig)) {
@@ -229,7 +305,7 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
loading={loading}
dataSource={items}
pagination={{ current: filters.page, pageSize: filters.pageSize, total, onChange: (page, pageSize) => setFilters(f => ({ ...f, page, pageSize })) }}
scroll={{ x: 1180 }}
scroll={{ x: 1380 }}
columns={[
{
title: '预览',
@@ -256,6 +332,18 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
{ title: '行业', dataIndex: 'categoryName' },
{ title: '类型', dataIndex: 'mediaType', render: (v: string) => v === 'image' ? '图片' : '视频' },
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag color={statusMap[v]?.color}>{statusMap[v]?.text || v}</Tag> },
{
title: '生成提词',
width: 210,
render: (_: unknown, row: HomeMaterialAsset) => row.generationPrompt ? (
<Tooltip title={row.generationPrompt}><Typography.Text>{shortText(row.generationPrompt)}</Typography.Text></Tooltip>
) : <Typography.Text type="secondary"></Typography.Text>,
},
{
title: '附件',
width: 90,
render: (_: unknown, row: HomeMaterialAsset) => <Tag color={(row.mediaReferences?.length || 0) > 0 ? 'blue' : 'default'}>{row.mediaReferences?.length || 0}</Tag>,
},
{
title: '水印',
render: (_: unknown, row: HomeMaterialAsset) => {
@@ -267,7 +355,7 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
{ title: '错误', dataIndex: 'errorMessage', ellipsis: true },
{
title: '操作',
width: 150,
width: 170,
align: 'center' as const,
render: (_: unknown, row: HomeMaterialAsset) => (
<Space size={8}>
@@ -276,10 +364,12 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
trigger={['click']}
menu={{
items: [
{ key: 'generation_config', icon: <EditOutlined />, label: '编辑生成配置' },
{ key: 'regenerate', icon: <ReloadOutlined />, label: '重新生成水印' },
{ key: 'delete', icon: <DeleteOutlined />, label: '删除素材', danger: true },
],
onClick: ({ key }) => {
if (key === 'generation_config') openGenerationConfig(row);
if (key === 'regenerate') openRegenerate(row);
if (key === 'delete') confirmDelete(row);
},
@@ -292,11 +382,21 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
},
]}
/>
<Modal title="素材详情" open={!!preview} onCancel={() => setPreview(null)} footer={null} width={900} destroyOnHidden>
<Modal title="素材详情" open={!!preview} onCancel={() => setPreview(null)} footer={null} width={960} destroyOnHidden>
{preview && (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<div><b></b>{preview.originalUrl ? apiUrl(preview.originalUrl) : '-'}</div>
<div><b></b>{preview.watermarkedUrl ? apiUrl(preview.watermarkedUrl) : '-'}</div>
<div>
<b></b>
<div style={{ marginTop: 8, padding: 12, borderRadius: 8, background: '#f8fafc', whiteSpace: 'pre-wrap' }}>
{preview.generationPrompt || '未设置'}
</div>
</div>
<div>
<b> / </b>
<div style={{ marginTop: 8 }}>{renderMediaReferences(preview.mediaReferences)}</div>
</div>
{preview.mediaType === 'image' ? (
<Image src={apiUrl(preview.watermarkedUrl || preview.originalUrl)} />
) : (
@@ -305,6 +405,38 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
</Space>
)}
</Modal>
<Modal
title="编辑生成配置"
open={!!editingConfig}
onCancel={() => !editSubmitting && setEditingConfig(null)}
onOk={submitGenerationConfig}
confirmLoading={editSubmitting}
cancelButtonProps={{ disabled: editSubmitting }}
width={920}
destroyOnHidden
>
<Space direction="vertical" style={{ width: '100%' }} size={16}>
<div>
<Typography.Text strong></Typography.Text>
<Input.TextArea
rows={6}
maxLength={8000}
showCount
value={editPrompt}
disabled={editSubmitting}
placeholder="请输入用于生成该素材的提示词"
onChange={(e) => setEditPrompt(e.target.value)}
style={{ marginTop: 8 }}
/>
</div>
<div>
<Typography.Text strong> / </Typography.Text>
<div style={{ marginTop: 8 }}>
<MediaReferencesEditor value={editRefs} onChange={setEditRefs} disabled={editSubmitting} />
</div>
</div>
</Space>
</Modal>
<Modal
title="重新生成水印"
open={!!regen}
@@ -2,8 +2,9 @@ import React, { useEffect, useMemo, useState } from 'react';
import { Form, Input, InputNumber, Modal, Select, Switch, Upload, Button, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import { uploadHomeMaterialAsset } from '../../api';
import type { HomeMaterialCategory, HomeMaterialMediaType, HomeMaterialWatermark, HomeMaterialWatermarkConfig } from '../../types';
import type { HomeMaterialCategory, HomeMaterialMediaReference, HomeMaterialMediaType, HomeMaterialWatermark, HomeMaterialWatermarkConfig } from '../../types';
import WatermarkEditor from './WatermarkEditor';
import MediaReferencesEditor from './MediaReferencesEditor';
interface Props {
open: boolean;
@@ -50,6 +51,7 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
const [config, setConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
const [submitting, setSubmitting] = useState(false);
const [mediaObjectUrl, setMediaObjectUrl] = useState<string>('');
const [mediaReferences, setMediaReferences] = useState<HomeMaterialMediaReference[]>([]);
const resetState = () => {
setFile(null);
@@ -57,6 +59,7 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
setMediaType('image');
setMediaObjectUrl('');
setSubmitting(false);
setMediaReferences([]);
};
useEffect(() => {
@@ -64,7 +67,7 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
resetState();
const defaultWatermark = watermarks.find(w => w.isDefault) || watermarks[0];
setConfig({ ...defaultConfig, watermarkId: defaultWatermark?.id || null });
form.setFieldsValue({ media_type: 'image', is_active: true, sort_order: 0, title: undefined });
form.setFieldsValue({ media_type: 'image', is_active: true, sort_order: 0, title: undefined, generation_prompt: undefined });
}
}, [open, watermarks, form]);
@@ -136,6 +139,8 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
file,
mediaType,
title: values.title?.trim() || null,
generationPrompt: values.generation_prompt?.trim() || null,
mediaReferences,
watermarkType: config.watermarkType,
watermarkId: config.watermarkType === 'image' ? config.watermarkId : null,
opacityLevel: config.opacityLevel,
@@ -197,6 +202,12 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
</Upload>
</Form.Item>
<Form.Item name="title" label="素材标题" extra="选填;不填时后台显示“未命名素材”,不会再自动使用文件名作为标题。"><Input disabled={submitting} /></Form.Item>
<Form.Item name="generation_prompt" label="生成提词" extra="选填;会随前台素材接口返回。">
<Input.TextArea rows={5} maxLength={8000} showCount disabled={submitting} placeholder="请输入用于生成该素材的提示词" />
</Form.Item>
<Form.Item label="附件 / 参考素材">
<MediaReferencesEditor value={mediaReferences} onChange={setMediaReferences} disabled={submitting} />
</Form.Item>
<Form.Item name="sort_order" label="排序"><InputNumber min={0} style={{ width: '100%' }} disabled={submitting} /></Form.Item>
<Form.Item name="is_active" label="前台展示" valuePropName="checked"><Switch disabled={submitting} /></Form.Item>
</Form>
@@ -0,0 +1,167 @@
import React, { useMemo, useState } from 'react';
import { Button, Card, Image, Input, InputNumber, Space, Tag, Upload, message } from 'antd';
import { DeleteOutlined, UploadOutlined } from '@ant-design/icons';
import { uploadAdminFile } from '../../api';
import type { AdminUploadResourceType, HomeMaterialMediaReference } from '../../types';
import { apiUrl } from '../../utils/resourceUrl';
interface Props {
value?: HomeMaterialMediaReference[] | null;
onChange: (value: HomeMaterialMediaReference[]) => void;
disabled?: boolean;
}
const mediaTypeText: Record<string, string> = {
image: '图片',
video: '视频',
audio: '音频',
};
function detectResourceType(file: File): AdminUploadResourceType | null {
if (file.type.startsWith('image/')) return 'image';
if (file.type.startsWith('video/')) return 'video';
if (file.type.startsWith('audio/')) return 'audio';
const name = file.name.toLowerCase();
if (/\.(jpg|jpeg|png|webp|gif)$/.test(name)) return 'image';
if (/\.(mp4|mov|m4v|webm)$/.test(name)) return 'video';
if (/\.(mp3|wav|m4a|aac)$/.test(name)) return 'audio';
return null;
}
function readDuration(file: File, resourceType: AdminUploadResourceType): Promise<number | null> {
if (resourceType !== 'video' && resourceType !== 'audio') return Promise.resolve(null);
return new Promise(resolve => {
const url = URL.createObjectURL(file);
const el = document.createElement(resourceType === 'video' ? 'video' : 'audio');
el.preload = 'metadata';
el.onloadedmetadata = () => {
const duration = Number.isFinite(el.duration) ? Number(el.duration.toFixed(3)) : null;
URL.revokeObjectURL(url);
resolve(duration);
};
el.onerror = () => {
URL.revokeObjectURL(url);
resolve(null);
};
el.src = url;
});
}
const MediaReferencesEditor: React.FC<Props> = ({ value, onChange, disabled }) => {
const refs = useMemo(() => value || [], [value]);
const [uploading, setUploading] = useState(false);
const updateItem = (index: number, patch: Partial<HomeMaterialMediaReference>) => {
onChange(refs.map((item, idx) => idx === index ? { ...item, ...patch } : item));
};
const removeItem = (index: number) => {
onChange(refs.filter((_, idx) => idx !== index));
};
const uploadFile = async (file: File) => {
const resourceType = detectResourceType(file);
if (!resourceType || !['image', 'video', 'audio'].includes(resourceType)) {
message.warning('附件仅支持图片、视频、音频');
return Upload.LIST_IGNORE;
}
setUploading(true);
try {
const durationSeconds = await readDuration(file, resourceType);
const res = await uploadAdminFile(file, {
scene: 'home_material_reference',
resourceType,
durationSeconds,
});
const ref = res.mediaReference;
if (!ref || !['image', 'video', 'audio'].includes(ref.type)) {
throw new Error('上传返回附件格式异常');
}
onChange([
...refs,
{
url: ref.url,
type: ref.type as 'image' | 'video' | 'audio',
name: ref.name || file.name,
duration: ref.duration ?? durationSeconds,
source: ref.source,
uploadResourceId: ref.uploadResourceId,
displayUrl: ref.displayUrl || ref.url,
previewUrl: ref.previewUrl || ref.displayUrl || ref.url,
role: ref.role || `reference_${ref.type}`,
},
]);
message.success('附件上传成功');
} catch (e: any) {
message.error(e?.message || '附件上传失败');
} finally {
setUploading(false);
}
return Upload.LIST_IGNORE;
};
return (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Upload beforeUpload={(file) => uploadFile(file as File)} showUploadList={false} disabled={disabled || uploading} accept="image/*,video/*,audio/*">
<Button icon={<UploadOutlined />} loading={uploading} disabled={disabled || uploading}></Button>
</Upload>
{refs.length === 0 ? (
<div style={{ color: '#999', fontSize: 13 }}></div>
) : (
<Space direction="vertical" style={{ width: '100%' }} size={10}>
{refs.map((ref, index) => {
const previewUrl = apiUrl(ref.previewUrl || ref.displayUrl || ref.url);
const displayUrl = apiUrl(ref.displayUrl || ref.url);
return (
<Card key={`${ref.url}-${index}`} size="small" bodyStyle={{ padding: 12 }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<div style={{ width: 120, minHeight: 72, flexShrink: 0, borderRadius: 8, overflow: 'hidden', background: '#f6f7fb', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{ref.type === 'image' && <Image width={120} height={72} style={{ objectFit: 'cover' }} src={previewUrl} />}
{ref.type === 'video' && <video src={displayUrl} style={{ width: 120, height: 72, objectFit: 'cover', background: '#000' }} controls />}
{ref.type === 'audio' && <audio src={displayUrl} controls style={{ width: 112 }} />}
</div>
<Space direction="vertical" style={{ flex: 1, minWidth: 0 }} size={8}>
<Space wrap>
<Tag color={ref.type === 'image' ? 'blue' : ref.type === 'video' ? 'purple' : 'green'}>{mediaTypeText[ref.type] || ref.type}</Tag>
{ref.uploadResourceId && <Tag>ID{ref.uploadResourceId}</Tag>}
</Space>
<Input
size="small"
value={ref.name || ''}
placeholder="附件名称"
disabled={disabled}
onChange={(e) => updateItem(index, { name: e.target.value || null })}
/>
<Input
size="small"
value={ref.role || ''}
placeholder="附件角色,如 reference_image"
disabled={disabled}
onChange={(e) => updateItem(index, { role: e.target.value || null })}
/>
{(ref.type === 'video' || ref.type === 'audio') && (
<InputNumber
size="small"
min={0}
precision={3}
style={{ width: 180 }}
value={ref.duration ?? null}
placeholder="时长(秒)"
disabled={disabled}
onChange={(v) => updateItem(index, { duration: v === null ? null : Number(v) })}
/>
)}
<Input size="small" value={ref.url} disabled />
</Space>
<Button danger size="small" icon={<DeleteOutlined />} disabled={disabled} onClick={() => removeItem(index)}></Button>
</div>
</Card>
);
})}
</Space>
)}
</Space>
);
};
export default MediaReferencesEditor;
+57
View File
@@ -176,6 +176,8 @@ export interface AdminTeamPayload {
description?: string | null;
status: 'active' | 'disabled';
sort_order: number;
generation_prompt?: string | null;
media_references?: HomeMaterialMediaReference[] | null;
}
export interface AdminUser {
@@ -877,6 +879,53 @@ export interface AdminCreditRecordQueryParams {
// ── 首页素材行业装修 ──────────────────────────────────────
export type AdminUploadScene =
| 'system_logo'
| 'system_pdf'
| 'open_type_thumb'
| 'home_material_reference'
| 'admin_common';
export type AdminUploadResourceType = 'image' | 'video' | 'audio' | 'pdf' | 'file';
export interface HomeMaterialMediaReference {
url: string;
type: 'image' | 'video' | 'audio';
name?: string | null;
duration?: number | null;
source?: string | null;
uploadResourceId?: string | null;
displayUrl?: string | null;
previewUrl?: string | null;
role?: string | null;
}
export interface AdminUploadMediaReference {
url: string;
type: AdminUploadResourceType;
name?: string | null;
duration?: number | null;
source: string;
uploadResourceId?: string | null;
displayUrl?: string | null;
previewUrl?: string | null;
role?: string | null;
}
export interface AdminUploadFileResult {
resourceId: string;
scene: AdminUploadScene;
module: string;
resourceType: AdminUploadResourceType;
url: string;
fileName: string;
originalFileName?: string | null;
fileSizeBytes: number;
durationSeconds?: number | null;
mediaReference?: AdminUploadMediaReference | null;
}
export type HomeMaterialMediaType = 'image' | 'video';
export type HomeMaterialAssetStatus = 'draft' | 'processing' | 'success' | 'failed';
export type HomeMaterialWatermarkType = 'image' | 'repeated_text';
@@ -998,6 +1047,8 @@ export interface HomeMaterialAsset {
watermarkId?: string | null;
watermarkName?: string | null;
watermarkConfig?: HomeMaterialWatermarkConfig | Record<string, unknown> | null;
generationPrompt?: string | null;
mediaReferences?: HomeMaterialMediaReference[] | null;
width?: number | null;
height?: number | null;
durationSeconds?: number | string | null;
@@ -1016,6 +1067,8 @@ export interface HomeMaterialAssetUpdatePayload {
title?: string | null;
is_active: boolean;
sort_order: number;
generation_prompt?: string | null;
media_references?: HomeMaterialMediaReference[] | null;
}
export interface HomeMaterialAssetStatusOut {
@@ -1038,6 +1091,8 @@ export interface HomeMaterialUploadResult {
watermarkedUrl?: string | null;
coverUrl?: string | null;
watermarkConfig?: HomeMaterialWatermarkConfig | Record<string, unknown> | null;
generationPrompt?: string | null;
mediaReferences?: HomeMaterialMediaReference[] | null;
message: string;
}
@@ -1074,6 +1129,8 @@ export interface HomeMaterialUploadAssetParams {
file: File;
mediaType: HomeMaterialMediaType;
title?: string | null;
generationPrompt?: string | null;
mediaReferences?: HomeMaterialMediaReference[] | null;
watermarkType: HomeMaterialWatermarkType;
watermarkId?: string | null;
watermarkFile?: File | null;