上传素材管控-历史素材success
This commit is contained in:
+169
-12
@@ -116,20 +116,30 @@ export async function optimizePrompt(
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadAudio(file: File): Promise<{ url: string; filename: string }> {
|
||||
export interface UploadResourceResult {
|
||||
url: string;
|
||||
filename: string;
|
||||
type?: string;
|
||||
module?: string;
|
||||
resource_id?: string;
|
||||
file_size_bytes?: number;
|
||||
duration_seconds?: number | null;
|
||||
}
|
||||
|
||||
export async function uploadAudio(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
|
||||
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-audio`, {
|
||||
const query = typeof durationSeconds === 'number' && durationSeconds > 0 ? `?duration_seconds=${encodeURIComponent(String(durationSeconds))}` : '';
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-audio${query}`, {
|
||||
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 };
|
||||
if (!res.ok) throw new Error('音频上传失败');
|
||||
return await res.json();
|
||||
}
|
||||
export async function uploadImage(file: File): Promise<{ url: string; filename: string }> {
|
||||
export async function uploadImage(file: File): Promise<UploadResourceResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
@@ -139,21 +149,74 @@ export async function uploadImage(file: File): Promise<{ url: string; filename:
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('图片上传失败');
|
||||
const data = await res.json();
|
||||
return { url: data.url, filename: data.filename };
|
||||
return await res.json();
|
||||
}
|
||||
export async function uploadVideo(file: File): Promise<{ url: string; filename: string }> {
|
||||
export async function uploadVideo(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
|
||||
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-video`, {
|
||||
const query = typeof durationSeconds === 'number' && durationSeconds > 0 ? `?duration_seconds=${encodeURIComponent(String(durationSeconds))}` : '';
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-video${query}`, {
|
||||
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 };
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function uploadHotOpeningImage(file: File): Promise<UploadResourceResult> {
|
||||
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/hot-opening-replications/upload-image`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('图片上传失败');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function uploadHotOpeningVideo(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const query = typeof durationSeconds === 'number' && durationSeconds > 0 ? `?duration_seconds=${encodeURIComponent(String(durationSeconds))}` : '';
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/hot-opening-replications/upload-video${query}`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('视频上传失败');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function uploadShotReplicateImage(file: File): Promise<UploadResourceResult> {
|
||||
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/shot-replications/upload-image`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('图片上传失败');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function uploadShotReplicateVideo(file: File, durationSeconds?: number): Promise<UploadResourceResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const query = typeof durationSeconds === 'number' && durationSeconds > 0 ? `?duration_seconds=${encodeURIComponent(String(durationSeconds))}` : '';
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/shot-replications/upload-video${query}`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('视频上传失败');
|
||||
return await res.json();
|
||||
}
|
||||
export async function deleteUpload(url: string): Promise<void> {
|
||||
await api.post(`/generation-records/delete-file?url=${encodeURIComponent(url)}`);
|
||||
@@ -741,6 +804,100 @@ export async function deleteResourcesMaterial(params:any): Promise<any> {
|
||||
return api.delete(`/generation-ai/history/batch`, params);
|
||||
}
|
||||
|
||||
// ── UploadResource History ───────────────────────────────
|
||||
export interface UploadResourceMediaReferenceOut {
|
||||
name?: string | null;
|
||||
type: 'image' | 'video' | 'audio';
|
||||
url: string;
|
||||
label?: string | null;
|
||||
duration?: number | null;
|
||||
source: 'upload_resource';
|
||||
uploadResourceId: string;
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryItemOut {
|
||||
id: string;
|
||||
sourceType: 'upload_resource';
|
||||
historySource: 'upload_resource';
|
||||
historySourceLabel: string;
|
||||
module: string;
|
||||
moduleLabel: string;
|
||||
resourceType: 'image' | 'video' | 'audio';
|
||||
resourceTypeLabel: string;
|
||||
resourceUrl: string;
|
||||
displayUrl: string;
|
||||
previewUrl: string;
|
||||
imageUrl?: string | null;
|
||||
videoUrl?: string | null;
|
||||
audioUrl?: string | null;
|
||||
fileName?: string | null;
|
||||
fileExt?: string | null;
|
||||
mimeType?: string | null;
|
||||
fileSizeBytes: number;
|
||||
durationSeconds?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
bindStatus: string;
|
||||
deletePolicy: string;
|
||||
deletable: boolean;
|
||||
mediaReference: UploadResourceMediaReferenceOut;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryDayGroupOut {
|
||||
generatedDate: string;
|
||||
total: number;
|
||||
page: number;
|
||||
items: UploadResourceHistoryItemOut[];
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryGroupedOut {
|
||||
totalDays: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
groups: UploadResourceHistoryDayGroupOut[];
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryDayItemsOut {
|
||||
generatedDate: string;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
items: UploadResourceHistoryItemOut[];
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryQueryParams {
|
||||
resourceType?: 'image' | 'video' | 'audio' | '';
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
scene?: 'record' | 'picker' | string;
|
||||
}
|
||||
|
||||
function buildUploadResourceHistoryQuery(params: UploadResourceHistoryQueryParams = {}): string {
|
||||
const query = new URLSearchParams();
|
||||
if (params.resourceType) query.set('resource_type', params.resourceType);
|
||||
if (params.page) query.set('page', String(params.page));
|
||||
if (params.pageSize) query.set('page_size', String(params.pageSize));
|
||||
if (params.keyword) query.set('keyword', params.keyword);
|
||||
if (params.scene) query.set('scene', params.scene);
|
||||
const qs = query.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
export async function getUploadResourceHistory(params: UploadResourceHistoryQueryParams = {}): Promise<UploadResourceHistoryGroupedOut> {
|
||||
return api.get<UploadResourceHistoryGroupedOut>(`/upload-resources/history${buildUploadResourceHistoryQuery(params)}`);
|
||||
}
|
||||
|
||||
export async function getUploadResourceHistoryItems(generatedDate: string, params: UploadResourceHistoryQueryParams = {}): Promise<UploadResourceHistoryDayItemsOut> {
|
||||
return api.get<UploadResourceHistoryDayItemsOut>(`/upload-resources/history/${generatedDate}${buildUploadResourceHistoryQuery(params)}`);
|
||||
}
|
||||
|
||||
export async function deleteUploadResourceHistoryBatch(resourceIds: string[]): Promise<any> {
|
||||
return api.delete('/upload-resources/history/batch', { resource_ids: resourceIds });
|
||||
}
|
||||
|
||||
|
||||
export async function getPrivatePortraitConfig(): Promise<PrivatePortraitConfig> {
|
||||
return api.get<PrivatePortraitConfig>('/private-portrait/config');
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Modal, Tooltip } from 'antd';
|
||||
import { HistoryOutlined, UserOutlined, FolderOpenOutlined, PlusOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset } from '../types';
|
||||
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset, UploadResourceHistoryItem } from '../types';
|
||||
import PrivatePortraitAssetPicker from './privatePortrait/picker/AssetPicker';
|
||||
import UploadResourceHistoryPicker from './uploadResource/UploadResourceHistoryPicker';
|
||||
|
||||
interface UploadSelectorProps {
|
||||
children: React.ReactNode;
|
||||
accept?: string;
|
||||
onLocalSelect?: (files: File[]) => void;
|
||||
onHistorySelect?: (items: any[]) => void;
|
||||
onHistorySelect?: (items: UploadResourceHistoryItem[]) => void;
|
||||
/** 兼容旧页面:由 UploadSelector 内部打开素材选择器,确认后回传素材数组。 */
|
||||
onPortraitSelect?: (items: PrivatePortraitSelectableAsset[]) => void;
|
||||
/** 新页面推荐:只选择素材库类型,父组件自行打开统一选择器。 */
|
||||
@@ -63,12 +64,6 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
||||
setPortraitPickerOpen(true);
|
||||
};
|
||||
|
||||
const confirmHistorySelection = () => {
|
||||
onHistorySelect?.([]);
|
||||
setHistoryModalVisible(false);
|
||||
setModalVisible(false);
|
||||
};
|
||||
|
||||
const options = [
|
||||
{
|
||||
key: 'history',
|
||||
@@ -192,39 +187,16 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="选择资产素材"
|
||||
<UploadResourceHistoryPicker
|
||||
open={historyModalVisible}
|
||||
onCancel={() => setHistoryModalVisible(false)}
|
||||
footer={null}
|
||||
width="80%"
|
||||
centered
|
||||
destroyOnHidden
|
||||
>
|
||||
<div style={{ minHeight: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ textAlign: 'center', color: '#94a3b8', fontSize: 14 }}>
|
||||
暂无资产图片
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16, paddingTop: 16, borderTop: '1px solid #e2e8f0' }}>
|
||||
<button
|
||||
onClick={confirmHistorySelection}
|
||||
style={{
|
||||
padding: '8px 24px',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
background: '#8b5cf6',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
应用
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
onClose={() => setHistoryModalVisible(false)}
|
||||
onSelect={(items) => {
|
||||
onHistorySelect?.(items);
|
||||
setHistoryModalVisible(false);
|
||||
setModalVisible(false);
|
||||
}}
|
||||
allowedTypes={accept.includes('audio') ? ['image', 'video', 'audio'] : accept.includes('video') ? ['image', 'video'] : ['image']}
|
||||
/>
|
||||
|
||||
<PrivatePortraitAssetPicker
|
||||
open={portraitPickerOpen}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AudioOutlined, DeleteOutlined, DownloadOutlined, EyeOutlined, PictureOutlined, ReloadOutlined, SearchOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||
import { Button, Checkbox, Empty, Input, message, Modal, Pagination, Select, Space, Spin, Tag, Typography } from 'antd';
|
||||
import { deleteUpload, deleteUploadResourceHistoryBatch, getUploadResourceHistory, getUploadResourceHistoryItems } from '../../api';
|
||||
import type { UploadResourceHistoryDayGroup, UploadResourceHistoryItem } from '../../types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type MediaTypeFilter = '' | 'image' | 'video' | 'audio';
|
||||
|
||||
const buildPreviewUrl = (url: string) => {
|
||||
if (!url) return '';
|
||||
if (/^(https?:|data:|blob:)/i.test(url)) return url;
|
||||
const base = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
|
||||
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
};
|
||||
|
||||
const typeIcon = (type: string) => {
|
||||
if (type === 'video') return <VideoCameraOutlined />;
|
||||
if (type === 'audio') return <AudioOutlined />;
|
||||
return <PictureOutlined />;
|
||||
};
|
||||
|
||||
const typeLabel = (type: string) => {
|
||||
if (type === 'video') return '视频';
|
||||
if (type === 'audio') return '音频';
|
||||
return '图片';
|
||||
};
|
||||
|
||||
const bytesText = (bytes?: number | null) => {
|
||||
const value = Number(bytes || 0);
|
||||
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${value} B`;
|
||||
};
|
||||
|
||||
const UploadResourceHistoryPanel: React.FC = () => {
|
||||
const [resourceType, setResourceType] = useState<MediaTypeFilter>('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [groups, setGroups] = useState<UploadResourceHistoryDayGroup[]>([]);
|
||||
const [totalDays, setTotalDays] = useState(0);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [previewItem, setPreviewItem] = useState<UploadResourceHistoryItem | null>(null);
|
||||
|
||||
const allItems = useMemo(() => groups.flatMap((group) => group.items || []), [groups]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getUploadResourceHistory({
|
||||
resourceType: resourceType || undefined,
|
||||
keyword: keyword || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
scene: 'record',
|
||||
});
|
||||
setGroups(res.groups || []);
|
||||
setTotalDays(res.totalDays || 0);
|
||||
setSelectedIds(new Set());
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载历史上传素材失败');
|
||||
setGroups([]);
|
||||
setTotalDays(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [keyword, page, pageSize, resourceType]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
const ids = allItems.map((item) => item.id);
|
||||
setSelectedIds((prev) => (prev.size === ids.length ? new Set() : new Set(ids)));
|
||||
};
|
||||
|
||||
const handleLoadMoreDay = async (group: UploadResourceHistoryDayGroup) => {
|
||||
const nextPage = (group.page || 1) + 1;
|
||||
try {
|
||||
const res = await getUploadResourceHistoryItems(group.generatedDate, {
|
||||
resourceType: resourceType || undefined,
|
||||
keyword: keyword || undefined,
|
||||
page: nextPage,
|
||||
pageSize: 10,
|
||||
scene: 'record',
|
||||
});
|
||||
setGroups((prev) => prev.map((item) => item.generatedDate === group.generatedDate ? { ...item, page: nextPage, items: [...item.items, ...(res.items || [])] } : item));
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载更多失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteOne = (item: UploadResourceHistoryItem) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除历史上传素材',
|
||||
content: `确定删除 ${item.fileName || item.id} 吗?删除后会释放上传容量,并在提交成功后清理真实文件。`,
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteUpload(item.resourceUrl);
|
||||
message.success('删除成功');
|
||||
load();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedIds.size === 0) {
|
||||
message.warning('请先选择要删除的上传素材');
|
||||
return;
|
||||
}
|
||||
if (selectedIds.size > 30) {
|
||||
message.warning('一次最多删除 30 条上传素材');
|
||||
return;
|
||||
}
|
||||
const ids = Array.from(selectedIds);
|
||||
Modal.confirm({
|
||||
title: '确认批量删除历史上传素材',
|
||||
content: `确定删除选中的 ${ids.length} 条上传素材吗?删除后会释放上传容量,并在提交成功后清理真实文件。`,
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await deleteUploadResourceHistoryBatch(ids);
|
||||
message.success(`删除成功,释放 ${bytesText(res?.releasedSizeBytes || 0)}`);
|
||||
load();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '批量删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownload = (item: UploadResourceHistoryItem) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = buildPreviewUrl(item.resourceUrl);
|
||||
link.download = item.fileName || item.id;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const renderMedia = (item: UploadResourceHistoryItem, size: 'card' | 'preview' = 'card') => {
|
||||
const url = buildPreviewUrl(item.previewUrl || item.displayUrl || item.resourceUrl);
|
||||
const style = size === 'card'
|
||||
? { width: '100%', height: 130, objectFit: 'cover' as const }
|
||||
: { width: '100%', maxHeight: 620, objectFit: 'contain' as const };
|
||||
if (item.resourceType === 'image') return <img src={url} alt={item.fileName || item.id} style={style} />;
|
||||
if (item.resourceType === 'video') return <video src={url} controls={size === 'preview'} muted={size === 'card'} style={style} />;
|
||||
return <audio src={url} controls style={{ width: '100%' }} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<Space wrap>
|
||||
<Select
|
||||
value={resourceType}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '图片', value: 'image' },
|
||||
{ label: '视频', value: 'video' },
|
||||
{ label: '音频', value: 'audio' },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setResourceType(value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
value={keyword}
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索文件名或URL"
|
||||
style={{ width: 240 }}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onPressEnter={() => {
|
||||
setPage(1);
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button onClick={selectAll}>{selectedIds.size && selectedIds.size === allItems.length ? '取消全选' : '全选'}</Button>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={handleBatchDelete}>批量删除 ({selectedIds.size})</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
{groups.length === 0 ? (
|
||||
<Empty description="暂无可展示的历史上传素材" style={{ padding: '60px 0' }} />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 28 }}>
|
||||
{groups.map((group) => (
|
||||
<div key={group.generatedDate}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#1e293b' }}>{group.generatedDate}</div>
|
||||
<Text type="secondary">共 {group.total} 个素材</Text>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(190px, 1fr))', gap: 16 }}>
|
||||
{group.items.map((item) => {
|
||||
const checked = selectedIds.has(item.id);
|
||||
return (
|
||||
<div key={item.id} style={{ border: checked ? '2px solid #8b5cf6' : '1px solid #e2e8f0', borderRadius: 16, overflow: 'hidden', background: '#fff', boxShadow: '0 10px 24px rgba(15,23,42,0.06)' }}>
|
||||
<div style={{ position: 'relative', background: '#f1f5f9' }}>
|
||||
{renderMedia(item)}
|
||||
<Checkbox checked={checked} onChange={() => toggle(item.id)} style={{ position: 'absolute', top: 10, left: 10, background: '#fff', borderRadius: 6, padding: 4 }} />
|
||||
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ position: 'absolute', top: 10, right: 10, margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
|
||||
</div>
|
||||
<div style={{ padding: 12 }}>
|
||||
<Text ellipsis title={item.fileName || item.id} style={{ display: 'block', fontWeight: 600, color: '#334155' }}>{item.fileName || item.id}</Text>
|
||||
<Space size={4} wrap style={{ marginTop: 8 }}>
|
||||
<Tag style={{ margin: 0 }}>{item.moduleLabel}</Tag>
|
||||
<Tag style={{ margin: 0 }}>{bytesText(item.fileSizeBytes)}</Tag>
|
||||
{item.durationSeconds ? <Tag style={{ margin: 0 }}>{Number(item.durationSeconds).toFixed(1)}s</Tag> : null}
|
||||
</Space>
|
||||
<Space style={{ marginTop: 12 }}>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => setPreviewItem(item)}>预览</Button>
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={() => handleDownload(item)}>下载</Button>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDeleteOne(item)}>删除</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{group.items.length < group.total && (
|
||||
<div style={{ textAlign: 'center', marginTop: 14 }}>
|
||||
<Button onClick={() => handleLoadMoreDay(group)}>加载更多</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 24 }}>
|
||||
<Pagination current={page} pageSize={pageSize} total={totalDays} showSizeChanger pageSizeOptions={[5, 10]} onChange={(nextPage, nextSize) => { setPage(nextPage); setPageSize(nextSize); }} />
|
||||
</div>
|
||||
|
||||
<Modal open={!!previewItem} title={previewItem?.fileName || '预览'} footer={null} width={900} centered destroyOnHidden onCancel={() => setPreviewItem(null)}>
|
||||
{previewItem ? renderMedia(previewItem, 'preview') : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadResourceHistoryPanel;
|
||||
@@ -0,0 +1,251 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AudioOutlined, CheckOutlined, PictureOutlined, ReloadOutlined, SearchOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||
import { Button, Empty, Input, message, Modal, Pagination, Select, Space, Spin, Tag, Typography } from 'antd';
|
||||
import { getUploadResourceHistoryItems } from '../../api';
|
||||
import type { UploadResourceHistoryItem } from '../../types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type MediaTypeFilter = '' | 'image' | 'video' | 'audio';
|
||||
|
||||
interface UploadResourceHistoryPickerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (items: UploadResourceHistoryItem[]) => void;
|
||||
allowedTypes?: Array<'image' | 'video' | 'audio'>;
|
||||
selectedIds?: string[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
const buildPreviewUrl = (url: string) => {
|
||||
if (!url) return '';
|
||||
if (/^(https?:|data:|blob:)/i.test(url)) return url;
|
||||
const base = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '');
|
||||
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
};
|
||||
|
||||
const typeIcon = (type: string) => {
|
||||
if (type === 'video') return <VideoCameraOutlined />;
|
||||
if (type === 'audio') return <AudioOutlined />;
|
||||
return <PictureOutlined />;
|
||||
};
|
||||
|
||||
const typeLabel = (type: string) => {
|
||||
if (type === 'video') return '视频';
|
||||
if (type === 'audio') return '音频';
|
||||
return '图片';
|
||||
};
|
||||
|
||||
const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
allowedTypes,
|
||||
selectedIds = [],
|
||||
title = '选择历史上传素材',
|
||||
}) => {
|
||||
const [date, setDate] = useState(today());
|
||||
const [resourceType, setResourceType] = useState<MediaTypeFilter>('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [items, setItems] = useState<UploadResourceHistoryItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [checkedMap, setCheckedMap] = useState<Map<string, UploadResourceHistoryItem>>(new Map());
|
||||
|
||||
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||
const effectiveAllowedTypes = useMemo(() => new Set(allowedTypes || ['image', 'video', 'audio']), [allowedTypes]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!open) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getUploadResourceHistoryItems(date, {
|
||||
resourceType: resourceType || undefined,
|
||||
keyword: keyword || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
scene: 'picker',
|
||||
});
|
||||
const filtered = (res.items || []).filter((item) => effectiveAllowedTypes.has(item.resourceType));
|
||||
setItems(filtered);
|
||||
setTotal(res.total || 0);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载历史上传素材失败');
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [date, effectiveAllowedTypes, keyword, open, page, pageSize, resourceType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) load();
|
||||
}, [open, load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setCheckedMap(new Map());
|
||||
}, [open]);
|
||||
|
||||
const toggle = (item: UploadResourceHistoryItem) => {
|
||||
if (selectedIdSet.has(item.id)) {
|
||||
message.warning('该素材已经在参考内容中');
|
||||
return;
|
||||
}
|
||||
setCheckedMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(item.id)) next.delete(item.id);
|
||||
else next.set(item.id, item);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
const selected = Array.from(checkedMap.values());
|
||||
if (selected.length === 0) {
|
||||
message.warning('请先选择历史素材');
|
||||
return;
|
||||
}
|
||||
onSelect(selected);
|
||||
setCheckedMap(new Map());
|
||||
onClose();
|
||||
};
|
||||
|
||||
const typeOptions = useMemo(() => {
|
||||
const all = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '图片', value: 'image' },
|
||||
{ label: '视频', value: 'video' },
|
||||
{ label: '音频', value: 'audio' },
|
||||
];
|
||||
return all.filter((opt) => !opt.value || effectiveAllowedTypes.has(opt.value as any));
|
||||
}, [effectiveAllowedTypes]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={920}
|
||||
centered
|
||||
destroyOnHidden
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onClose}>取消</Button>,
|
||||
<Button key="ok" type="primary" onClick={confirm} style={{ background: '#8b5cf6' }}>
|
||||
应用选择 ({checkedMap.size})
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Space wrap style={{ width: '100%', marginBottom: 16 }}>
|
||||
<Input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => {
|
||||
setDate(e.target.value || today());
|
||||
setPage(1);
|
||||
}}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<Select
|
||||
value={resourceType}
|
||||
options={typeOptions}
|
||||
style={{ width: 120 }}
|
||||
onChange={(value) => {
|
||||
setResourceType(value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
value={keyword}
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索文件名"
|
||||
style={{ width: 220 }}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onPressEnter={() => {
|
||||
setPage(1);
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
|
||||
</Space>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
{items.length === 0 ? (
|
||||
<Empty description="暂无可复用的历史上传素材" style={{ padding: '48px 0' }} />
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14, minHeight: 260 }}>
|
||||
{items.map((item) => {
|
||||
const checked = checkedMap.has(item.id);
|
||||
const disabled = selectedIdSet.has(item.id);
|
||||
const preview = buildPreviewUrl(item.previewUrl || item.displayUrl || item.resourceUrl);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => !disabled && toggle(item)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
borderRadius: 14,
|
||||
border: checked ? '2px solid #8b5cf6' : '1px solid #e2e8f0',
|
||||
background: disabled ? '#f8fafc' : '#fff',
|
||||
opacity: disabled ? 0.55 : 1,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
overflow: 'hidden',
|
||||
boxShadow: checked ? '0 10px 24px rgba(139, 92, 246, 0.18)' : '0 6px 18px rgba(15,23,42,0.06)',
|
||||
}}
|
||||
>
|
||||
<div style={{ height: 112, background: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{item.resourceType === 'image' ? (
|
||||
<img src={preview} alt={item.fileName || item.id} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : item.resourceType === 'video' ? (
|
||||
<video src={preview} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{ width: 58, height: 58, borderRadius: 18, background: 'linear-gradient(135deg, #8b5cf6, #a78bfa)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 24 }}>
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: 10 }}>
|
||||
<Space size={6} style={{ marginBottom: 6 }}>
|
||||
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
|
||||
{item.durationSeconds ? <Tag style={{ margin: 0 }}>{Number(item.durationSeconds).toFixed(1)}s</Tag> : null}
|
||||
</Space>
|
||||
<Text ellipsis style={{ display: 'block', fontSize: 13, color: '#334155' }} title={item.fileName || item.id}>
|
||||
{item.fileName || item.id}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{item.moduleLabel || '普通上传'}</Text>
|
||||
</div>
|
||||
{checked && (
|
||||
<div style={{ position: 'absolute', top: 8, right: 8, width: 24, height: 24, borderRadius: 12, background: '#8b5cf6', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<CheckOutlined />
|
||||
</div>
|
||||
)}
|
||||
{disabled && <div style={{ position: 'absolute', top: 8, right: 8 }}><Tag>已选</Tag></div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
showSizeChanger
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
onChange={(nextPage, nextSize) => {
|
||||
setPage(nextPage);
|
||||
setPageSize(nextSize);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadResourceHistoryPicker;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as UploadResourceHistoryPicker } from './UploadResourceHistoryPicker';
|
||||
export { default as UploadResourceHistoryPanel } from './UploadResourceHistoryPanel';
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { PrivatePortraitAssetPicker } from '../components/privatePortrait';
|
||||
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset } from '../types';
|
||||
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset, UploadResourceHistoryItem } from '../types';
|
||||
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -78,8 +78,10 @@ interface MediaReference {
|
||||
source?: string;
|
||||
private_asset_id?: string;
|
||||
remote_asset_id?: string;
|
||||
upload_resource_id?: string;
|
||||
}
|
||||
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
original_prompt: string;
|
||||
@@ -1503,6 +1505,48 @@ const AIChatPage: React.FC = () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
const normalizeUploadResourceHistoryItem = (item: UploadResourceHistoryItem): MediaReference | null => {
|
||||
const media = item.mediaReference;
|
||||
const refType = (media?.type || item.resourceType) as 'image' | 'video' | 'audio';
|
||||
const url = media?.url || item.resourceUrl || item.displayUrl || item.previewUrl || '';
|
||||
if (!url) {
|
||||
message.error(`${item.fileName || item.id} 缺少素材地址,不能用于 AI 创作`);
|
||||
return null;
|
||||
}
|
||||
if (refType === 'audio' && mediaType !== 'video') {
|
||||
message.error('仅视频模式支持添加音频素材');
|
||||
return null;
|
||||
}
|
||||
const duration = Number(media?.duration ?? item.durationSeconds);
|
||||
return {
|
||||
name: media?.name || item.fileName || item.id,
|
||||
type: refType,
|
||||
url,
|
||||
source: 'upload_resource',
|
||||
upload_resource_id: item.id,
|
||||
label: '',
|
||||
...((refType === 'video' || refType === 'audio') && Number.isFinite(duration) && duration > 0 ? { duration } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const handleUploadResourceHistorySelected = (items: UploadResourceHistoryItem[]) => {
|
||||
const normalized = items
|
||||
.map(normalizeUploadResourceHistoryItem)
|
||||
.filter(Boolean) as MediaReference[];
|
||||
if (!normalized.length) return;
|
||||
|
||||
const latestMedia = useAppStore.getState().currentMedia as MediaReference[];
|
||||
if (!validateMediaReferencesBeforeAdd(latestMedia, normalized)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newList = [...latestMedia, ...normalized];
|
||||
const labels = generateMediaLabels(newList);
|
||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||
message.success(`已添加 ${normalized.length} 个历史上传素材参考`);
|
||||
};
|
||||
|
||||
const normalizePrivatePortraitAsset = (asset: PrivatePortraitSelectableAsset): MediaReference | null => {
|
||||
if (asset.assetType === 'Audio') {
|
||||
message.error('音频私域素材暂不支持用于 AI 创作');
|
||||
@@ -2612,16 +2656,7 @@ const AIChatPage: React.FC = () => {
|
||||
<UploadSelector
|
||||
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*,audio/*'}
|
||||
onLocalSelect={handleBatchUpload}
|
||||
onHistorySelect={(items) => {
|
||||
const newMedia = items.map((item: any) => ({
|
||||
name: item.name,
|
||||
type: item.type as 'image' | 'video' | 'audio',
|
||||
url: '',
|
||||
label: '',
|
||||
}));
|
||||
setCurrentMedia([...currentMedia, ...newMedia]);
|
||||
message.success(`成功添加${items.length}个历史记录`);
|
||||
}}
|
||||
onHistorySelect={handleUploadResourceHistorySelected}
|
||||
onPortraitLibrarySelect={(libraryType) => {
|
||||
openPrivatePortraitPicker(libraryType);
|
||||
}}
|
||||
@@ -2792,16 +2827,7 @@ const AIChatPage: React.FC = () => {
|
||||
<UploadSelector
|
||||
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*,audio/*'}
|
||||
onLocalSelect={handleBatchUpload}
|
||||
onHistorySelect={(items) => {
|
||||
const newMedia = items.map((item: any) => ({
|
||||
name: item.name,
|
||||
type: item.type as 'image' | 'video' | 'audio',
|
||||
url: '',
|
||||
label: '',
|
||||
}));
|
||||
setCurrentMedia([...currentMedia, ...newMedia]);
|
||||
message.success(`成功添加${items.length}个历史记录`);
|
||||
}}
|
||||
onHistorySelect={handleUploadResourceHistorySelected}
|
||||
onPortraitLibrarySelect={(libraryType) => {
|
||||
openPrivatePortraitPicker(libraryType);
|
||||
}}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { PrivatePortraitLibraryPanel } from '../components/privatePortrait';
|
||||
import { UploadResourceHistoryPanel } from '../components/uploadResource';
|
||||
import { gethistory, gethistoryItems, getOAuthList, asyncBatchUploadMaterial, updateFilename, getUploadHistory, getAllOAuthAccountList, getOpenTypeAll, getPreTestList, getDefaultPreTest, deleteHistory, deleteResourcesMaterial } from '../api';
|
||||
|
||||
const { Search } = Input;
|
||||
@@ -52,8 +53,8 @@ const GeneratedRecord: React.FC = () => {
|
||||
});
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialFilterType = searchParams.get('filterType') === 'private_portrait' ? 'private_portrait' : 'project';
|
||||
const [filterType, setFilterType] = useState<'project' | 'creation' | 'hot_opening_replicate' | 'shot_replicate' | 'private_portrait'>(initialFilterType);
|
||||
const initialFilterType = searchParams.get('filterType') === 'private_portrait' ? 'private_portrait' : searchParams.get('filterType') === 'upload_resource' ? 'upload_resource' : 'project';
|
||||
const [filterType, setFilterType] = useState<'project' | 'creation' | 'hot_opening_replicate' | 'shot_replicate' | 'private_portrait' | 'upload_resource'>(initialFilterType);
|
||||
const [filterMedia, setFilterMedia] = useState<'video' | 'image'>('video');
|
||||
const [recordlist, setRecordList] = useState<any[]>([]);
|
||||
const [Pagebreak, setPagebreak] = useState<any>({
|
||||
@@ -759,7 +760,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
});
|
||||
};
|
||||
const loadRecordList = () => {
|
||||
if (filterType === 'private_portrait') {
|
||||
if (filterType === 'private_portrait' || filterType === 'upload_resource') {
|
||||
setLoading(false);
|
||||
setRecordList([]);
|
||||
return;
|
||||
@@ -1033,9 +1034,29 @@ const GeneratedRecord: React.FC = () => {
|
||||
>
|
||||
私域素材库
|
||||
</Button>
|
||||
<Button
|
||||
type={filterType === 'upload_resource' ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
setFilterType('upload_resource');
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: filterType === 'upload_resource'
|
||||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||||
: '#f8f9fc',
|
||||
border: filterType === 'upload_resource' ? 'none' : '1px solid #e2e8f0',
|
||||
color: filterType === 'upload_resource' ? '#fff' : '#64748b',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
icon={<UploadOutlined />}
|
||||
>
|
||||
历史素材
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
{filterType !== 'private_portrait' && (
|
||||
{filterType !== 'private_portrait' && filterType !== 'upload_resource' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
{/* 多选模式按钮 */}
|
||||
{isSelectionMode ? (
|
||||
@@ -1120,6 +1141,8 @@ const GeneratedRecord: React.FC = () => {
|
||||
</div>
|
||||
{filterType === 'private_portrait' ? (
|
||||
<PrivatePortraitLibraryPanel />
|
||||
) : filterType === 'upload_resource' ? (
|
||||
<UploadResourceHistoryPanel />
|
||||
) : (
|
||||
<>
|
||||
{/* Second row filter: 视频 / 图片 */}
|
||||
|
||||
@@ -18,11 +18,20 @@ import {
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { uploadVideo, uploadImage, generateReplication, getReplicationList, getone, getReplicationDetail } from '../api';
|
||||
import { uploadHotOpeningVideo, uploadHotOpeningImage, generateReplication, getReplicationList, getone, getReplicationDetail } from '../api';
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
|
||||
const buildAssetUrl = (url?: string): string => {
|
||||
if (!url) return '';
|
||||
if (/^https?:\/\//i.test(url) || url.startsWith('blob:')) return url;
|
||||
return `${API_BASE}${url}`;
|
||||
};
|
||||
|
||||
|
||||
const GenerateConver: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -50,8 +59,11 @@ const GenerateConver: React.FC = () => {
|
||||
// 文件状态
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||
const [videoResourceId, setVideoResourceId] = useState<string>('');
|
||||
const [videoDurationSeconds, setVideoDurationSeconds] = useState<number | null>(null);
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imageUrl, setImageUrl] = useState<string>('');
|
||||
const [imageResourceId, setImageResourceId] = useState<string>('');
|
||||
const [videoUploading, setVideoUploading] = useState(false);
|
||||
const [imageUploading, setImageUploading] = useState(false);
|
||||
|
||||
@@ -222,9 +234,11 @@ const GenerateConver: React.FC = () => {
|
||||
// 调用上传接口
|
||||
setVideoUploading(true);
|
||||
try {
|
||||
const res = await uploadVideo(file);
|
||||
const res = await uploadHotOpeningVideo(file, video.duration);
|
||||
setVideoFile(file);
|
||||
setVideoUrl(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${res.url}`);
|
||||
setVideoUrl(res.url);
|
||||
setVideoResourceId(res.resource_id || '');
|
||||
setVideoDurationSeconds(video.duration);
|
||||
message.success('视频上传成功');
|
||||
resolve(false);
|
||||
} catch (error) {
|
||||
@@ -280,9 +294,10 @@ const GenerateConver: React.FC = () => {
|
||||
// 调用上传接口
|
||||
setImageUploading(true);
|
||||
try {
|
||||
const res = await uploadImage(file);
|
||||
const res = await uploadHotOpeningImage(file);
|
||||
setImageFile(file);
|
||||
setImageUrl(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${res.url}`);
|
||||
setImageUrl(res.url);
|
||||
setImageResourceId(res.resource_id || '');
|
||||
message.success('图片上传成功');
|
||||
resolve(false);
|
||||
} catch (error) {
|
||||
@@ -363,6 +378,9 @@ const GenerateConver: React.FC = () => {
|
||||
let params = {
|
||||
material_video_url: videoUrl,
|
||||
material_image_url: imageUrl,
|
||||
material_video_resource_id: videoResourceId || undefined,
|
||||
material_image_resource_id: imageResourceId || undefined,
|
||||
material_video_duration_seconds: videoDurationSeconds || undefined,
|
||||
source_project_name: originalProductName,
|
||||
target_project_name: ownProductName,
|
||||
core_content_point: productSellingPoints,
|
||||
@@ -380,7 +398,10 @@ const GenerateConver: React.FC = () => {
|
||||
|
||||
// 清空上传的媒体和文本
|
||||
setVideoUrl('');
|
||||
setVideoResourceId('');
|
||||
setVideoDurationSeconds(null);
|
||||
setImageUrl('');
|
||||
setImageResourceId('');
|
||||
setOriginalProductName('');
|
||||
setOwnProductName('');
|
||||
setProductSellingPoints('');
|
||||
@@ -818,7 +839,7 @@ const GenerateConver: React.FC = () => {
|
||||
{videoUrl ? (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<video
|
||||
src={videoUrl}
|
||||
src={buildAssetUrl(videoUrl)}
|
||||
controls
|
||||
style={{ width: '100%', borderRadius: 12, maxHeight: 200, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.08)' }}
|
||||
/>
|
||||
@@ -934,7 +955,7 @@ const GenerateConver: React.FC = () => {
|
||||
{imageUrl ? (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<img
|
||||
src={imageUrl}
|
||||
src={buildAssetUrl(imageUrl)}
|
||||
alt="产品图片"
|
||||
style={{ width: '100%', borderRadius: 12, maxHeight: 200, objectFit: 'contain', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.08)' }}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Button, Drawer, Input, Modal, Popconfirm, Spin, Table, Tag, Tooltip, Upload, message } from 'antd';
|
||||
import { ArrowLeftOutlined, PlusOutlined, XOutlined } from '@ant-design/icons';
|
||||
import { createRemoveLens, getShotReplicationDetail, Removelist, removeCreate, splitCustom, uploadImage } from '../api';
|
||||
import { createRemoveLens, getShotReplicationDetail, Removelist, removeCreate, splitCustom, uploadShotReplicateImage } from '../api';
|
||||
import VideoTrimPicker from '../components/VideoTrimPicker';
|
||||
|
||||
const { TextArea } = Input;
|
||||
@@ -30,6 +30,7 @@ function RemoveInfo() {
|
||||
const [productName, setProductName] = useState('');
|
||||
const [productSellingPoint, setProductSellingPoint] = useState('');
|
||||
const [productImage, setProductImage] = useState('');
|
||||
const [productImageResourceId, setProductImageResourceId] = useState('');
|
||||
const [taskDetail, setTaskDetail] = useState<any>(null);
|
||||
const [tableData, setTableData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -190,18 +191,21 @@ function RemoveInfo() {
|
||||
setCurrentSegmentName('');
|
||||
setProductSellingPoint('');
|
||||
setProductImage('');
|
||||
setProductImageResourceId('');
|
||||
};
|
||||
|
||||
const handleProductImageChange: any = (info: any) => {
|
||||
if (info.fileList.length === 0) {
|
||||
setProductImage('');
|
||||
setProductImageResourceId('');
|
||||
}
|
||||
};
|
||||
|
||||
const beforeUploadProductImage = async (file: File) => {
|
||||
try {
|
||||
const uploadResult = await uploadImage(file);
|
||||
const uploadResult = await uploadShotReplicateImage(file);
|
||||
setProductImage(uploadResult.url);
|
||||
setProductImageResourceId(uploadResult.resource_id || '');
|
||||
message.success('图片上传成功');
|
||||
} catch {
|
||||
message.error('图片上传失败,请重试');
|
||||
@@ -231,7 +235,8 @@ function RemoveInfo() {
|
||||
const params = {
|
||||
target_project_name: productName.trim(),
|
||||
core_content_point: productSellingPoint.trim(),
|
||||
material_image_url: buildAssetUrl(productImage),
|
||||
material_image_url: productImage,
|
||||
material_image_resource_id: productImageResourceId || undefined,
|
||||
idempotency_key: `replication_${Date.now()}`,
|
||||
};
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@ import { useState, useRef, useCallback } from 'react';
|
||||
import { Button, Modal, Input, Table, Upload, Popconfirm, message } from 'antd';
|
||||
import { FileTextOutlined, CloudUploadOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { uploadVideo, createShotReplication, getShotReplicationList } from '../api';
|
||||
import { uploadShotReplicateVideo, createShotReplication, getShotReplicationList } from '../api';
|
||||
import bg1 from '../assets/bg1.png';
|
||||
|
||||
export default function VideoFrameExtractor() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||
const [videoResourceId, setVideoResourceId] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [productName, setProductName] = useState<string>('');
|
||||
@@ -28,6 +29,7 @@ export default function VideoFrameExtractor() {
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
}
|
||||
setVideoUrl('');
|
||||
setVideoResourceId('');
|
||||
setError('');
|
||||
setVideoDuration(0);
|
||||
setProductName('');
|
||||
@@ -66,8 +68,9 @@ export default function VideoFrameExtractor() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const uploadResult = await uploadVideo(file);
|
||||
const uploadResult = await uploadShotReplicateVideo(file, duration);
|
||||
setVideoUrl(uploadResult.url);
|
||||
setVideoResourceId(uploadResult.resource_id || '');
|
||||
setError('');
|
||||
message.success('视频上传成功');
|
||||
} catch (err) {
|
||||
@@ -97,6 +100,7 @@ export default function VideoFrameExtractor() {
|
||||
const params = {
|
||||
video_url: videoUrl,
|
||||
video_duration_seconds: videoDuration,
|
||||
video_resource_id: videoResourceId || undefined,
|
||||
title: productName.trim(),
|
||||
idempotency_key: `shot_${Date.now()}`,
|
||||
};
|
||||
|
||||
@@ -139,8 +139,10 @@ export interface MediaReference {
|
||||
providerUrl?: string;
|
||||
displayUrl?: string;
|
||||
previewUrl?: string;
|
||||
upload_resource_id?: string;
|
||||
}
|
||||
|
||||
|
||||
export interface GenerationRecord {
|
||||
id: string;
|
||||
items:[],
|
||||
@@ -386,3 +388,69 @@ export interface PrivatePortraitSelectableAssetListOut {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── UploadResource History Types ──────────────────────────
|
||||
export type UploadResourceType = 'image' | 'video' | 'audio';
|
||||
|
||||
export interface UploadResourceMediaReference {
|
||||
name?: string | null;
|
||||
type: UploadResourceType;
|
||||
url: string;
|
||||
label?: string | null;
|
||||
duration?: number | null;
|
||||
source: 'upload_resource';
|
||||
uploadResourceId: string;
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryItem {
|
||||
id: string;
|
||||
sourceType: 'upload_resource';
|
||||
historySource: 'upload_resource';
|
||||
historySourceLabel: string;
|
||||
module: string;
|
||||
moduleLabel: string;
|
||||
resourceType: UploadResourceType;
|
||||
resourceTypeLabel: string;
|
||||
resourceUrl: string;
|
||||
displayUrl: string;
|
||||
previewUrl: string;
|
||||
imageUrl?: string | null;
|
||||
videoUrl?: string | null;
|
||||
audioUrl?: string | null;
|
||||
fileName?: string | null;
|
||||
fileExt?: string | null;
|
||||
mimeType?: string | null;
|
||||
fileSizeBytes: number;
|
||||
durationSeconds?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
bindStatus: string;
|
||||
deletePolicy: string;
|
||||
deletable: boolean;
|
||||
mediaReference: UploadResourceMediaReference;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryDayGroup {
|
||||
generatedDate: string;
|
||||
total: number;
|
||||
page: number;
|
||||
items: UploadResourceHistoryItem[];
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryGrouped {
|
||||
totalDays: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
groups: UploadResourceHistoryDayGroup[];
|
||||
}
|
||||
|
||||
export interface UploadResourceHistoryDayItems {
|
||||
generatedDate: string;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
items: UploadResourceHistoryItem[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user