管理后台上传集成API | 首页素材装修追加提词/附件
This commit is contained in:
@@ -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') {
|
||||
|
||||
@@ -105,7 +105,11 @@ const AdminSettings: React.FC = () => {
|
||||
const res = await uploadLogo(file);
|
||||
setConfigs(prev => prev.map(c => c.key === 'site_logo' ? { ...c, value: res.url } : c));
|
||||
form.setFieldsValue({ site_logo: res.url });
|
||||
message.success('Logo上传成功');
|
||||
const config = configs.find(c => c.key === 'site_logo');
|
||||
if (config) {
|
||||
await updateSystemConfig(config.id, res.url);
|
||||
}
|
||||
message.success('Logo上传成功并已保存');
|
||||
} catch {
|
||||
message.error('上传失败');
|
||||
} finally {
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Dropdown, Image, Modal, Select, Space, Table, Tag, message } from 'antd';
|
||||
import { DeleteOutlined, MoreOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { Button, Dropdown, Image, Input, Modal, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
|
||||
import { DeleteOutlined, EditOutlined, MoreOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
deleteHomeMaterialAsset,
|
||||
getHomeMaterialAssetStatus,
|
||||
getHomeMaterialAssets,
|
||||
regenerateHomeMaterialWatermark,
|
||||
updateHomeMaterialAsset,
|
||||
} from '../../api';
|
||||
import type {
|
||||
HomeMaterialAsset,
|
||||
HomeMaterialAssetQueryParams,
|
||||
HomeMaterialCategory,
|
||||
HomeMaterialMediaReference,
|
||||
HomeMaterialWatermark,
|
||||
HomeMaterialWatermarkConfig,
|
||||
HomeMaterialTextWatermarkConfig,
|
||||
} from '../../types';
|
||||
import { apiUrl } from '../../utils/resourceUrl';
|
||||
import WatermarkEditor from './WatermarkEditor';
|
||||
import MediaReferencesEditor from './MediaReferencesEditor';
|
||||
|
||||
interface Props {
|
||||
categories: HomeMaterialCategory[];
|
||||
@@ -57,6 +60,12 @@ const defaultConfig: HomeMaterialWatermarkConfig = {
|
||||
textWatermark: defaultTextWatermark,
|
||||
};
|
||||
|
||||
const mediaTypeText: Record<string, string> = {
|
||||
image: '图片',
|
||||
video: '视频',
|
||||
audio: '音频',
|
||||
};
|
||||
|
||||
function normalizeTextConfig(raw: any): HomeMaterialTextWatermarkConfig {
|
||||
return {
|
||||
text: raw?.text || defaultTextWatermark.text,
|
||||
@@ -94,6 +103,43 @@ function isValidConfig(config: HomeMaterialWatermarkConfig): boolean {
|
||||
return !!config.watermarkId;
|
||||
}
|
||||
|
||||
function shortText(value?: string | null, max = 48): string {
|
||||
const text = (value || '').trim();
|
||||
if (!text) return '';
|
||||
return text.length > max ? `${text.slice(0, max)}...` : text;
|
||||
}
|
||||
|
||||
function renderMediaReferences(refs?: HomeMaterialMediaReference[] | null) {
|
||||
const list = refs || [];
|
||||
if (list.length === 0) return <Typography.Text type="secondary">暂无附件</Typography.Text>;
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||
{list.map((ref, index) => {
|
||||
const displayUrl = apiUrl(ref.displayUrl || ref.url);
|
||||
const previewUrl = apiUrl(ref.previewUrl || ref.displayUrl || ref.url);
|
||||
return (
|
||||
<div key={`${ref.url}-${index}`} style={{ border: '1px solid #f0f0f5', borderRadius: 8, padding: 12, display: 'flex', gap: 12 }}>
|
||||
<div style={{ width: 140, minHeight: 80, flexShrink: 0, background: '#f6f7fb', borderRadius: 8, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{ref.type === 'image' && <Image width={140} height={80} style={{ objectFit: 'cover' }} src={previewUrl} />}
|
||||
{ref.type === 'video' && <video src={displayUrl} controls style={{ width: 140, height: 80, objectFit: 'cover', background: '#000' }} />}
|
||||
{ref.type === 'audio' && <audio src={displayUrl} controls style={{ width: 132 }} />}
|
||||
</div>
|
||||
<Space direction="vertical" size={6} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Space wrap>
|
||||
<Tag>{mediaTypeText[ref.type] || ref.type}</Tag>
|
||||
{ref.duration !== undefined && ref.duration !== null && <Tag>{ref.duration}s</Tag>}
|
||||
{ref.role && <Tag>{ref.role}</Tag>}
|
||||
</Space>
|
||||
<Typography.Text strong>{ref.name || '未命名附件'}</Typography.Text>
|
||||
<Typography.Text copyable ellipsis style={{ maxWidth: 620 }}>{ref.url}</Typography.Text>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloadKey }) => {
|
||||
const [items, setItems] = useState<HomeMaterialAsset[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -103,6 +149,10 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
||||
const [regen, setRegen] = useState<HomeMaterialAsset | null>(null);
|
||||
const [regenConfig, setRegenConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
|
||||
const [regenSubmitting, setRegenSubmitting] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<HomeMaterialAsset | null>(null);
|
||||
const [editPrompt, setEditPrompt] = useState('');
|
||||
const [editRefs, setEditRefs] = useState<HomeMaterialMediaReference[]>([]);
|
||||
const [editSubmitting, setEditSubmitting] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
@@ -133,6 +183,12 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
||||
setRegenConfig(normalizeConfig(row.watermarkConfig, row.watermarkId));
|
||||
};
|
||||
|
||||
const openGenerationConfig = (row: HomeMaterialAsset) => {
|
||||
setEditingConfig(row);
|
||||
setEditPrompt(row.generationPrompt || '');
|
||||
setEditRefs(row.mediaReferences || []);
|
||||
};
|
||||
|
||||
const getPreviewImageUrl = (row: HomeMaterialAsset) => {
|
||||
if (row.mediaType === 'image') return apiUrl(row.watermarkedUrl || row.originalUrl);
|
||||
return apiUrl(row.coverUrl || '');
|
||||
@@ -155,6 +211,26 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
||||
});
|
||||
};
|
||||
|
||||
const submitGenerationConfig = async () => {
|
||||
if (!editingConfig || editSubmitting) return;
|
||||
setEditSubmitting(true);
|
||||
try {
|
||||
await updateHomeMaterialAsset(editingConfig.id, {
|
||||
category_id: editingConfig.categoryId,
|
||||
title: editingConfig.title || null,
|
||||
is_active: editingConfig.isActive,
|
||||
sort_order: editingConfig.sortOrder,
|
||||
generation_prompt: editPrompt.trim() || null,
|
||||
media_references: editRefs,
|
||||
});
|
||||
message.success('生成配置已保存');
|
||||
setEditingConfig(null);
|
||||
await load();
|
||||
} finally {
|
||||
setEditSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitRegenerate = async () => {
|
||||
if (!regen || regenSubmitting) return;
|
||||
if (!isValidConfig(regenConfig)) {
|
||||
@@ -229,7 +305,7 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
pagination={{ current: filters.page, pageSize: filters.pageSize, total, onChange: (page, pageSize) => setFilters(f => ({ ...f, page, pageSize })) }}
|
||||
scroll={{ x: 1180 }}
|
||||
scroll={{ x: 1380 }}
|
||||
columns={[
|
||||
{
|
||||
title: '预览',
|
||||
@@ -256,6 +332,18 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
||||
{ title: '行业', dataIndex: 'categoryName' },
|
||||
{ title: '类型', dataIndex: 'mediaType', render: (v: string) => v === 'image' ? '图片' : '视频' },
|
||||
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag color={statusMap[v]?.color}>{statusMap[v]?.text || v}</Tag> },
|
||||
{
|
||||
title: '生成提词',
|
||||
width: 210,
|
||||
render: (_: unknown, row: HomeMaterialAsset) => row.generationPrompt ? (
|
||||
<Tooltip title={row.generationPrompt}><Typography.Text>{shortText(row.generationPrompt)}</Typography.Text></Tooltip>
|
||||
) : <Typography.Text type="secondary">未设置</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '附件',
|
||||
width: 90,
|
||||
render: (_: unknown, row: HomeMaterialAsset) => <Tag color={(row.mediaReferences?.length || 0) > 0 ? 'blue' : 'default'}>{row.mediaReferences?.length || 0}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '水印',
|
||||
render: (_: unknown, row: HomeMaterialAsset) => {
|
||||
@@ -267,7 +355,7 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
||||
{ title: '错误', dataIndex: 'errorMessage', ellipsis: true },
|
||||
{
|
||||
title: '操作',
|
||||
width: 150,
|
||||
width: 170,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: HomeMaterialAsset) => (
|
||||
<Space size={8}>
|
||||
@@ -276,10 +364,12 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'generation_config', icon: <EditOutlined />, label: '编辑生成配置' },
|
||||
{ key: 'regenerate', icon: <ReloadOutlined />, label: '重新生成水印' },
|
||||
{ key: 'delete', icon: <DeleteOutlined />, label: '删除素材', danger: true },
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'generation_config') openGenerationConfig(row);
|
||||
if (key === 'regenerate') openRegenerate(row);
|
||||
if (key === 'delete') confirmDelete(row);
|
||||
},
|
||||
@@ -292,11 +382,21 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Modal title="素材详情" open={!!preview} onCancel={() => setPreview(null)} footer={null} width={900} destroyOnHidden>
|
||||
<Modal title="素材详情" open={!!preview} onCancel={() => setPreview(null)} footer={null} width={960} destroyOnHidden>
|
||||
{preview && (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<div><b>原素材:</b>{preview.originalUrl ? apiUrl(preview.originalUrl) : '-'}</div>
|
||||
<div><b>水印素材:</b>{preview.watermarkedUrl ? apiUrl(preview.watermarkedUrl) : '-'}</div>
|
||||
<div>
|
||||
<b>生成提词:</b>
|
||||
<div style={{ marginTop: 8, padding: 12, borderRadius: 8, background: '#f8fafc', whiteSpace: 'pre-wrap' }}>
|
||||
{preview.generationPrompt || '未设置'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<b>附件 / 参考素材:</b>
|
||||
<div style={{ marginTop: 8 }}>{renderMediaReferences(preview.mediaReferences)}</div>
|
||||
</div>
|
||||
{preview.mediaType === 'image' ? (
|
||||
<Image src={apiUrl(preview.watermarkedUrl || preview.originalUrl)} />
|
||||
) : (
|
||||
@@ -305,6 +405,38 @@ const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloa
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal
|
||||
title="编辑生成配置"
|
||||
open={!!editingConfig}
|
||||
onCancel={() => !editSubmitting && setEditingConfig(null)}
|
||||
onOk={submitGenerationConfig}
|
||||
confirmLoading={editSubmitting}
|
||||
cancelButtonProps={{ disabled: editSubmitting }}
|
||||
width={920}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={16}>
|
||||
<div>
|
||||
<Typography.Text strong>生成提词</Typography.Text>
|
||||
<Input.TextArea
|
||||
rows={6}
|
||||
maxLength={8000}
|
||||
showCount
|
||||
value={editPrompt}
|
||||
disabled={editSubmitting}
|
||||
placeholder="请输入用于生成该素材的提示词"
|
||||
onChange={(e) => setEditPrompt(e.target.value)}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong>附件 / 参考素材</Typography.Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<MediaReferencesEditor value={editRefs} onChange={setEditRefs} disabled={editSubmitting} />
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
<Modal
|
||||
title="重新生成水印"
|
||||
open={!!regen}
|
||||
|
||||
@@ -2,8 +2,9 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Form, Input, InputNumber, Modal, Select, Switch, Upload, Button, message } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import { uploadHomeMaterialAsset } from '../../api';
|
||||
import type { HomeMaterialCategory, HomeMaterialMediaType, HomeMaterialWatermark, HomeMaterialWatermarkConfig } from '../../types';
|
||||
import type { HomeMaterialCategory, HomeMaterialMediaReference, HomeMaterialMediaType, HomeMaterialWatermark, HomeMaterialWatermarkConfig } from '../../types';
|
||||
import WatermarkEditor from './WatermarkEditor';
|
||||
import MediaReferencesEditor from './MediaReferencesEditor';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -50,6 +51,7 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
|
||||
const [config, setConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [mediaObjectUrl, setMediaObjectUrl] = useState<string>('');
|
||||
const [mediaReferences, setMediaReferences] = useState<HomeMaterialMediaReference[]>([]);
|
||||
|
||||
const resetState = () => {
|
||||
setFile(null);
|
||||
@@ -57,6 +59,7 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
|
||||
setMediaType('image');
|
||||
setMediaObjectUrl('');
|
||||
setSubmitting(false);
|
||||
setMediaReferences([]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -64,7 +67,7 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
|
||||
resetState();
|
||||
const defaultWatermark = watermarks.find(w => w.isDefault) || watermarks[0];
|
||||
setConfig({ ...defaultConfig, watermarkId: defaultWatermark?.id || null });
|
||||
form.setFieldsValue({ media_type: 'image', is_active: true, sort_order: 0, title: undefined });
|
||||
form.setFieldsValue({ media_type: 'image', is_active: true, sort_order: 0, title: undefined, generation_prompt: undefined });
|
||||
}
|
||||
}, [open, watermarks, form]);
|
||||
|
||||
@@ -136,6 +139,8 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
|
||||
file,
|
||||
mediaType,
|
||||
title: values.title?.trim() || null,
|
||||
generationPrompt: values.generation_prompt?.trim() || null,
|
||||
mediaReferences,
|
||||
watermarkType: config.watermarkType,
|
||||
watermarkId: config.watermarkType === 'image' ? config.watermarkId : null,
|
||||
opacityLevel: config.opacityLevel,
|
||||
@@ -197,6 +202,12 @@ const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, w
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="素材标题" extra="选填;不填时后台显示“未命名素材”,不会再自动使用文件名作为标题。"><Input disabled={submitting} /></Form.Item>
|
||||
<Form.Item name="generation_prompt" label="生成提词" extra="选填;会随前台素材接口返回。">
|
||||
<Input.TextArea rows={5} maxLength={8000} showCount disabled={submitting} placeholder="请输入用于生成该素材的提示词" />
|
||||
</Form.Item>
|
||||
<Form.Item label="附件 / 参考素材">
|
||||
<MediaReferencesEditor value={mediaReferences} onChange={setMediaReferences} disabled={submitting} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序"><InputNumber min={0} style={{ width: '100%' }} disabled={submitting} /></Form.Item>
|
||||
<Form.Item name="is_active" label="前台展示" valuePropName="checked"><Switch disabled={submitting} /></Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Button, Card, Image, Input, InputNumber, Space, Tag, Upload, message } from 'antd';
|
||||
import { DeleteOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import { uploadAdminFile } from '../../api';
|
||||
import type { AdminUploadResourceType, HomeMaterialMediaReference } from '../../types';
|
||||
import { apiUrl } from '../../utils/resourceUrl';
|
||||
|
||||
interface Props {
|
||||
value?: HomeMaterialMediaReference[] | null;
|
||||
onChange: (value: HomeMaterialMediaReference[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const mediaTypeText: Record<string, string> = {
|
||||
image: '图片',
|
||||
video: '视频',
|
||||
audio: '音频',
|
||||
};
|
||||
|
||||
function detectResourceType(file: File): AdminUploadResourceType | null {
|
||||
if (file.type.startsWith('image/')) return 'image';
|
||||
if (file.type.startsWith('video/')) return 'video';
|
||||
if (file.type.startsWith('audio/')) return 'audio';
|
||||
const name = file.name.toLowerCase();
|
||||
if (/\.(jpg|jpeg|png|webp|gif)$/.test(name)) return 'image';
|
||||
if (/\.(mp4|mov|m4v|webm)$/.test(name)) return 'video';
|
||||
if (/\.(mp3|wav|m4a|aac)$/.test(name)) return 'audio';
|
||||
return null;
|
||||
}
|
||||
|
||||
function readDuration(file: File, resourceType: AdminUploadResourceType): Promise<number | null> {
|
||||
if (resourceType !== 'video' && resourceType !== 'audio') return Promise.resolve(null);
|
||||
return new Promise(resolve => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const el = document.createElement(resourceType === 'video' ? 'video' : 'audio');
|
||||
el.preload = 'metadata';
|
||||
el.onloadedmetadata = () => {
|
||||
const duration = Number.isFinite(el.duration) ? Number(el.duration.toFixed(3)) : null;
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(duration);
|
||||
};
|
||||
el.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(null);
|
||||
};
|
||||
el.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
const MediaReferencesEditor: React.FC<Props> = ({ value, onChange, disabled }) => {
|
||||
const refs = useMemo(() => value || [], [value]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const updateItem = (index: number, patch: Partial<HomeMaterialMediaReference>) => {
|
||||
onChange(refs.map((item, idx) => idx === index ? { ...item, ...patch } : item));
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
onChange(refs.filter((_, idx) => idx !== index));
|
||||
};
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
const resourceType = detectResourceType(file);
|
||||
if (!resourceType || !['image', 'video', 'audio'].includes(resourceType)) {
|
||||
message.warning('附件仅支持图片、视频、音频');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const durationSeconds = await readDuration(file, resourceType);
|
||||
const res = await uploadAdminFile(file, {
|
||||
scene: 'home_material_reference',
|
||||
resourceType,
|
||||
durationSeconds,
|
||||
});
|
||||
const ref = res.mediaReference;
|
||||
if (!ref || !['image', 'video', 'audio'].includes(ref.type)) {
|
||||
throw new Error('上传返回附件格式异常');
|
||||
}
|
||||
onChange([
|
||||
...refs,
|
||||
{
|
||||
url: ref.url,
|
||||
type: ref.type as 'image' | 'video' | 'audio',
|
||||
name: ref.name || file.name,
|
||||
duration: ref.duration ?? durationSeconds,
|
||||
source: ref.source,
|
||||
uploadResourceId: ref.uploadResourceId,
|
||||
displayUrl: ref.displayUrl || ref.url,
|
||||
previewUrl: ref.previewUrl || ref.displayUrl || ref.url,
|
||||
role: ref.role || `reference_${ref.type}`,
|
||||
},
|
||||
]);
|
||||
message.success('附件上传成功');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '附件上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
return Upload.LIST_IGNORE;
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||
<Upload beforeUpload={(file) => uploadFile(file as File)} showUploadList={false} disabled={disabled || uploading} accept="image/*,video/*,audio/*">
|
||||
<Button icon={<UploadOutlined />} loading={uploading} disabled={disabled || uploading}>上传附件</Button>
|
||||
</Upload>
|
||||
{refs.length === 0 ? (
|
||||
<div style={{ color: '#999', fontSize: 13 }}>暂无附件。可上传图片、视频、音频,保存后会随前台素材接口返回。</div>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={10}>
|
||||
{refs.map((ref, index) => {
|
||||
const previewUrl = apiUrl(ref.previewUrl || ref.displayUrl || ref.url);
|
||||
const displayUrl = apiUrl(ref.displayUrl || ref.url);
|
||||
return (
|
||||
<Card key={`${ref.url}-${index}`} size="small" bodyStyle={{ padding: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
<div style={{ width: 120, minHeight: 72, flexShrink: 0, borderRadius: 8, overflow: 'hidden', background: '#f6f7fb', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{ref.type === 'image' && <Image width={120} height={72} style={{ objectFit: 'cover' }} src={previewUrl} />}
|
||||
{ref.type === 'video' && <video src={displayUrl} style={{ width: 120, height: 72, objectFit: 'cover', background: '#000' }} controls />}
|
||||
{ref.type === 'audio' && <audio src={displayUrl} controls style={{ width: 112 }} />}
|
||||
</div>
|
||||
<Space direction="vertical" style={{ flex: 1, minWidth: 0 }} size={8}>
|
||||
<Space wrap>
|
||||
<Tag color={ref.type === 'image' ? 'blue' : ref.type === 'video' ? 'purple' : 'green'}>{mediaTypeText[ref.type] || ref.type}</Tag>
|
||||
{ref.uploadResourceId && <Tag>资源ID:{ref.uploadResourceId}</Tag>}
|
||||
</Space>
|
||||
<Input
|
||||
size="small"
|
||||
value={ref.name || ''}
|
||||
placeholder="附件名称"
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateItem(index, { name: e.target.value || null })}
|
||||
/>
|
||||
<Input
|
||||
size="small"
|
||||
value={ref.role || ''}
|
||||
placeholder="附件角色,如 reference_image"
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateItem(index, { role: e.target.value || null })}
|
||||
/>
|
||||
{(ref.type === 'video' || ref.type === 'audio') && (
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
precision={3}
|
||||
style={{ width: 180 }}
|
||||
value={ref.duration ?? null}
|
||||
placeholder="时长(秒)"
|
||||
disabled={disabled}
|
||||
onChange={(v) => updateItem(index, { duration: v === null ? null : Number(v) })}
|
||||
/>
|
||||
)}
|
||||
<Input size="small" value={ref.url} disabled />
|
||||
</Space>
|
||||
<Button danger size="small" icon={<DeleteOutlined />} disabled={disabled} onClick={() => removeItem(index)}>删除</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaReferencesEditor;
|
||||
@@ -176,6 +176,8 @@ export interface AdminTeamPayload {
|
||||
description?: string | null;
|
||||
status: 'active' | 'disabled';
|
||||
sort_order: number;
|
||||
generation_prompt?: string | null;
|
||||
media_references?: HomeMaterialMediaReference[] | null;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
@@ -877,6 +879,53 @@ export interface AdminCreditRecordQueryParams {
|
||||
|
||||
// ── 首页素材行业装修 ──────────────────────────────────────
|
||||
|
||||
|
||||
export type AdminUploadScene =
|
||||
| 'system_logo'
|
||||
| 'system_pdf'
|
||||
| 'open_type_thumb'
|
||||
| 'home_material_reference'
|
||||
| 'admin_common';
|
||||
|
||||
export type AdminUploadResourceType = 'image' | 'video' | 'audio' | 'pdf' | 'file';
|
||||
|
||||
export interface HomeMaterialMediaReference {
|
||||
url: string;
|
||||
type: 'image' | 'video' | 'audio';
|
||||
name?: string | null;
|
||||
duration?: number | null;
|
||||
source?: string | null;
|
||||
uploadResourceId?: string | null;
|
||||
displayUrl?: string | null;
|
||||
previewUrl?: string | null;
|
||||
role?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminUploadMediaReference {
|
||||
url: string;
|
||||
type: AdminUploadResourceType;
|
||||
name?: string | null;
|
||||
duration?: number | null;
|
||||
source: string;
|
||||
uploadResourceId?: string | null;
|
||||
displayUrl?: string | null;
|
||||
previewUrl?: string | null;
|
||||
role?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminUploadFileResult {
|
||||
resourceId: string;
|
||||
scene: AdminUploadScene;
|
||||
module: string;
|
||||
resourceType: AdminUploadResourceType;
|
||||
url: string;
|
||||
fileName: string;
|
||||
originalFileName?: string | null;
|
||||
fileSizeBytes: number;
|
||||
durationSeconds?: number | null;
|
||||
mediaReference?: AdminUploadMediaReference | null;
|
||||
}
|
||||
|
||||
export type HomeMaterialMediaType = 'image' | 'video';
|
||||
export type HomeMaterialAssetStatus = 'draft' | 'processing' | 'success' | 'failed';
|
||||
export type HomeMaterialWatermarkType = 'image' | 'repeated_text';
|
||||
@@ -998,6 +1047,8 @@ export interface HomeMaterialAsset {
|
||||
watermarkId?: string | null;
|
||||
watermarkName?: string | null;
|
||||
watermarkConfig?: HomeMaterialWatermarkConfig | Record<string, unknown> | null;
|
||||
generationPrompt?: string | null;
|
||||
mediaReferences?: HomeMaterialMediaReference[] | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
durationSeconds?: number | string | null;
|
||||
@@ -1016,6 +1067,8 @@ export interface HomeMaterialAssetUpdatePayload {
|
||||
title?: string | null;
|
||||
is_active: boolean;
|
||||
sort_order: number;
|
||||
generation_prompt?: string | null;
|
||||
media_references?: HomeMaterialMediaReference[] | null;
|
||||
}
|
||||
|
||||
export interface HomeMaterialAssetStatusOut {
|
||||
@@ -1038,6 +1091,8 @@ export interface HomeMaterialUploadResult {
|
||||
watermarkedUrl?: string | null;
|
||||
coverUrl?: string | null;
|
||||
watermarkConfig?: HomeMaterialWatermarkConfig | Record<string, unknown> | null;
|
||||
generationPrompt?: string | null;
|
||||
mediaReferences?: HomeMaterialMediaReference[] | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -1074,6 +1129,8 @@ export interface HomeMaterialUploadAssetParams {
|
||||
file: File;
|
||||
mediaType: HomeMaterialMediaType;
|
||||
title?: string | null;
|
||||
generationPrompt?: string | null;
|
||||
mediaReferences?: HomeMaterialMediaReference[] | null;
|
||||
watermarkType: HomeMaterialWatermarkType;
|
||||
watermarkId?: string | null;
|
||||
watermarkFile?: File | null;
|
||||
|
||||
Reference in New Issue
Block a user