历史资源管理

This commit is contained in:
sjy
2026-07-09 18:22:50 +08:00
parent 6bc05a00d0
commit 0231067770
10 changed files with 657 additions and 233 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-CYMIldpb.js"></script>
<script type="module" crossorigin src="/assets/index-Bykj74Cw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-JhRVnnL-.css">
</head>
<body>
+5
View File
@@ -645,6 +645,11 @@ export async function deleteSegment(segmentId: string): Promise<void> {
export async function reanalyzeSegment(segmentId: string): Promise<any> {
return api.post(`/shot-replications/segments/${segmentId}/reanalyze`);
}
export async function retrySplit(segmentId: string): Promise<any> {
return api.post(`/shot-replications/segments/${segmentId}/retry-split`);
}
// 删除拆镜项目
export async function deleteShotReplicationProject(taskSetId: string): Promise<void> {
await api.delete(`/shot-replications/task-sets/${taskSetId}`);
@@ -26,6 +26,10 @@ interface UploadSelectorProps {
usedVideoCount?: number;
usedVideoDuration?: number;
maxVideoDuration?: number;
maxAudioCount?: number;
usedAudioCount?: number;
maxAudioDuration?: number;
usedAudioDuration?: number;
}
const UploadSelector: React.FC<UploadSelectorProps> = ({
@@ -44,12 +48,17 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
usedVideoCount,
usedVideoDuration,
maxVideoDuration,
maxAudioCount,
usedAudioCount,
maxAudioDuration,
usedAudioDuration,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [portraitPickerOpen, setPortraitPickerOpen] = useState(false);
const [portraitLibraryType, setPortraitLibraryType] = useState<PrivatePortraitLibraryType>('real_person');
const [historyModalVisible, setHistoryModalVisible] = useState(false);
const [popoverOpen, setPopoverOpen] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const handleLocalSelect = () => {
setPopoverOpen(false);
@@ -224,6 +233,16 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
setHistoryModalVisible(false);
}}
allowedTypes={accept.includes('audio') ? ['image', 'video', 'audio'] : accept.includes('video') ? ['image', 'video'] : ['image']}
maxImageCount={maxImageCount}
maxVideoCount={maxVideoCount}
usedImageCount={usedImageCount}
usedVideoCount={usedVideoCount}
maxVideoDuration={maxVideoDuration}
usedVideoDuration={usedVideoDuration}
maxAudioCount={maxAudioCount}
usedAudioCount={usedAudioCount}
maxAudioDuration={maxAudioDuration}
usedAudioDuration={usedAudioDuration}
/>
<PrivatePortraitAssetPicker
@@ -44,6 +44,7 @@ const UploadResourceHistoryPanel: React.FC = () => {
const [totalDays, setTotalDays] = useState(0);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [previewItem, setPreviewItem] = useState<UploadResourceHistoryItem | null>(null);
const [batchMode, setBatchMode] = useState(false);
const allItems = useMemo(() => groups.flatMap((group) => group.items || []), [groups]);
@@ -199,11 +200,18 @@ const UploadResourceHistoryPanel: React.FC = () => {
load();
}}
/>
<Button icon={<ReloadOutlined />} onClick={load}></Button>
{/* <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>
{batchMode ? (
<>
<Button onClick={selectAll}>{selectedIds.size && selectedIds.size === allItems.length ? '取消全选' : '全选'}</Button>
<Button danger icon={<DeleteOutlined />} onClick={handleBatchDelete}> ({selectedIds.size})</Button>
<Button onClick={() => { setBatchMode(false); setSelectedIds(new Set()); }}></Button>
</>
) : (
<Button onClick={() => setBatchMode(true)}></Button>
)}
</Space>
</div>
@@ -225,7 +233,7 @@ const UploadResourceHistoryPanel: React.FC = () => {
<div key={item.id} style={{ width: '17%', minWidth: 240, margin: 16, border: checked ? '2px solid #8b5cf6' : '1px solid #e2e8f0', borderRadius: 16, overflow: 'hidden', background: '#fff', boxShadow: '0 10px 24px rgba(15,23,42,0.06)' }}>
<div style={{ position: 'relative', background: '#f1f5f9' }}>
{renderMedia(item)}
<Checkbox checked={checked} onChange={() => toggle(item.id)} style={{ position: 'absolute', top: 10, left: 10, background: '#fff', borderRadius: 6, padding: 4 }} />
{batchMode && <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 ,paddingTop: 0}}>
@@ -1,7 +1,7 @@
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 { getUploadResourceHistoryItems ,getUploadResourceHistory} from '../../api';
import type { UploadResourceHistoryItem } from '../../types';
const { Text } = Typography;
@@ -15,10 +15,18 @@ interface UploadResourceHistoryPickerProps {
allowedTypes?: Array<'image' | 'video' | 'audio'>;
selectedIds?: string[];
title?: string;
maxImageCount?: number;
maxVideoCount?: number;
usedImageCount?: number;
usedVideoCount?: number;
maxVideoDuration?: number;
usedVideoDuration?: number;
maxAudioCount?: number;
usedAudioCount?: number;
maxAudioDuration?: number;
usedAudioDuration?: number;
}
const today = () => new Date().toISOString().slice(0, 10);
const buildPreviewUrl = (url: string) => {
if (!url) return '';
if (/^(https?:|data:|blob:)/i.test(url)) return url;
@@ -45,29 +53,93 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
allowedTypes,
selectedIds = [],
title = '选择历史上传素材',
maxImageCount,
maxVideoCount,
usedImageCount,
usedVideoCount,
maxVideoDuration,
usedVideoDuration,
maxAudioCount,
usedAudioCount,
maxAudioDuration,
usedAudioDuration,
}) => {
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 [groupPage, setGroupPage] = useState(1);
const [groupPageSize, setGroupPageSize] = useState(10);
const [itemPage, setItemPage] = useState(1);
const [itemPageSize, setItemPageSize] = useState(10);
const [loading, setLoading] = useState(false);
const [loadingGroups, setLoadingGroups] = useState(false);
const [groups, setGroups] = useState<any[]>([]);
const [totalDays, setTotalDays] = useState(0);
const [selectedDate, setSelectedDate] = useState<string>('');
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 effectiveAllowedTypes = useMemo(() => new Set(allowedTypes || ['image', 'video', 'audio']), [JSON.stringify(allowedTypes)]);
const load = useCallback(async () => {
const selectedImages = useMemo(() => Array.from(checkedMap.values()).filter(item => item.resourceType === 'image').length, [checkedMap]);
const selectedVideos = useMemo(() => Array.from(checkedMap.values()).filter(item => item.resourceType === 'video').length, [checkedMap]);
const selectedVideoDuration = useMemo(() => Array.from(checkedMap.values())
.filter(item => item.resourceType === 'video')
.reduce((sum, item) => sum + (item.durationSeconds || 0), 0), [checkedMap]);
const selectedAudios = useMemo(() => Array.from(checkedMap.values()).filter(item => item.resourceType === 'audio').length, [checkedMap]);
const selectedAudioDuration = useMemo(() => Array.from(checkedMap.values())
.filter(item => item.resourceType === 'audio')
.reduce((sum, item) => sum + (item.durationSeconds || 0), 0), [checkedMap]);
const maxAvailableImages = useMemo(() => maxImageCount !== undefined && usedImageCount !== undefined ? maxImageCount - usedImageCount : Infinity, [maxImageCount, usedImageCount]);
const maxAvailableVideos = useMemo(() => maxVideoCount !== undefined && usedVideoCount !== undefined ? maxVideoCount - usedVideoCount : Infinity, [maxVideoCount, usedVideoCount]);
const maxAvailableDuration = useMemo(() => maxVideoDuration !== undefined && usedVideoDuration !== undefined ? maxVideoDuration - usedVideoDuration : Infinity, [maxVideoDuration, usedVideoDuration]);
const maxAvailableAudios = useMemo(() => maxAudioCount !== undefined && usedAudioCount !== undefined ? maxAudioCount - usedAudioCount : Infinity, [maxAudioCount, usedAudioCount]);
const maxAvailableAudioDuration = useMemo(() => maxAudioDuration !== undefined && usedAudioDuration !== undefined ? maxAudioDuration - usedAudioDuration : Infinity, [maxAudioDuration, usedAudioDuration]);
const imageExceeded = useMemo(() => selectedImages > maxAvailableImages, [selectedImages, maxAvailableImages]);
const videoExceeded = useMemo(() => selectedVideos > maxAvailableVideos, [selectedVideos, maxAvailableVideos]);
const durationExceeded = useMemo(() => selectedVideoDuration > maxAvailableDuration, [selectedVideoDuration, maxAvailableDuration]);
const audioExceeded = useMemo(() => selectedAudios > maxAvailableAudios, [selectedAudios, maxAvailableAudios]);
const audioDurationExceeded = useMemo(() => selectedAudioDuration > maxAvailableAudioDuration, [selectedAudioDuration, maxAvailableAudioDuration]);
const loadGroups = useCallback(async () => {
if (!open) return;
setLoadingGroups(true);
try {
const res = await getUploadResourceHistory({
resourceType: resourceType || undefined,
keyword: keyword || undefined,
page: groupPage,
pageSize: groupPageSize,
scene: 'picker',
});
if (groupPage === 1) {
setGroups(res.groups || []);
} else {
setGroups((prev) => [...prev, ...(res.groups || [])]);
}
setTotalDays(res.totalDays || 0);
} catch (error: any) {
message.error(error?.message || '加载历史上传素材失败');
if (groupPage === 1) {
setGroups([]);
}
setTotalDays(0);
} finally {
setLoadingGroups(false);
}
}, [effectiveAllowedTypes, keyword, open, groupPage, groupPageSize, resourceType]);
const loadItems = useCallback(async (date: string) => {
setLoading(true);
try {
const res = await getUploadResourceHistoryItems(date, {
resourceType: resourceType || undefined,
keyword: keyword || undefined,
page,
pageSize,
page: itemPage,
pageSize: 8,
scene: 'picker',
});
const filtered = (res.items || []).filter((item) => effectiveAllowedTypes.has(item.resourceType));
@@ -80,11 +152,23 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
} finally {
setLoading(false);
}
}, [date, effectiveAllowedTypes, keyword, open, page, pageSize, resourceType]);
}, [effectiveAllowedTypes, keyword, itemPage, itemPageSize, resourceType]);
useEffect(() => {
if (open) load();
}, [open, load]);
if (open) loadGroups();
}, [open, groupPage, loadGroups]);
useEffect(() => {
if (selectedDate) {
setItemPage(1);
}
}, [selectedDate]);
useEffect(() => {
if (selectedDate) {
loadItems(selectedDate);
}
}, [selectedDate, itemPage, loadItems]);
useEffect(() => {
if (open) setCheckedMap(new Map());
@@ -129,122 +213,169 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
title={title}
open={open}
onCancel={onClose}
width={920}
width={1000}
centered
destroyOnHidden
footer={[
<Button key="cancel" onClick={onClose}></Button>,
<Button key="ok" type="primary" onClick={confirm} style={{ background: '#8b5cf6' }}>
<Button key="ok" type="primary" onClick={confirm} style={{ background: '#8b5cf6' }} disabled={imageExceeded || videoExceeded || durationExceeded || audioExceeded || audioDurationExceeded}>
({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>
<div style={{ marginBottom: 16 }}>
<Space wrap>
<Select
value={resourceType}
options={typeOptions}
style={{ width: 120 }}
onChange={(value) => {
setResourceType(value);
setGroupPage(1);
}}
/>
<Input
allowClear
value={keyword}
prefix={<SearchOutlined />}
placeholder="搜索文件名"
style={{ width: 220 }}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => {
setGroupPage(1);
loadGroups();
}}
/>
<Button icon={<ReloadOutlined />} onClick={loadGroups}></Button>
</Space>
<div style={{ display: 'flex', gap: 16, marginTop: 12, flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: imageExceeded ? '#ef4444' : '#64748b', fontWeight: imageExceeded ? 600 : 400 }}>
{selectedImages}/{maxImageCount !== undefined && usedImageCount !== undefined ? maxImageCount - usedImageCount : '-'}
</span>
<span style={{ fontSize: 13, color: videoExceeded || durationExceeded ? '#ef4444' : '#64748b', fontWeight: videoExceeded || durationExceeded ? 600 : 400 }}>
{selectedVideos}/{maxVideoCount !== undefined && usedVideoCount !== undefined ? maxVideoCount - usedVideoCount : '-'} {selectedVideoDuration.toFixed(1)}/{maxVideoDuration !== undefined && usedVideoDuration !== undefined ? (maxVideoDuration - usedVideoDuration).toFixed(1) : '-'}
</span>
{maxAudioCount !== undefined && (
<span style={{ fontSize: 13, color: audioExceeded || audioDurationExceeded ? '#ef4444' : '#64748b', fontWeight: audioExceeded || audioDurationExceeded ? 600 : 400 }}>
{selectedAudios}/{maxAudioCount !== undefined && usedAudioCount !== undefined ? maxAudioCount - usedAudioCount : '-'} {selectedAudioDuration.toFixed(1)}/{maxAudioDuration !== undefined && usedAudioDuration !== undefined ? (maxAudioDuration - usedAudioDuration).toFixed(1) : '-'}
</span>
)}
</div>
</div>
<Spin spinning={loading}>
{items.length === 0 ? (
<Empty description="暂无可复用的历史上传素材" style={{ padding: '48px 0' }} />
) : (
<div style={{ height: '500px', overflowY: 'auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14 }}>
{items.map((item) => {
const checked = checkedMap.has(item.id);
const disabled = selectedIdSet.has(item.id);
const preview = buildPreviewUrl(item.previewUrl || item.displayUrl || item.resourceUrl);
return (
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 16, height: 500 }}>
<div style={{ border: '1px solid #eef0f4', borderRadius: 12, padding: 12, background: '#fafafa', overflowY: 'auto' }}>
<Text strong style={{ fontSize: 14 }}></Text>
<Spin spinning={loadingGroups}>
{groups.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无日期" style={{ padding: '40px 0' }} />
) : (
<div style={{ marginTop: 12 }}>
{groups.map((group) => (
<div
key={item.id}
onClick={() => !disabled && toggle(item)}
key={group.generatedDate}
onClick={() => setSelectedDate(group.generatedDate)}
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)',
padding: '10px 12px',
borderRadius: 10,
marginBottom: 6,
cursor: 'pointer',
border: selectedDate === group.generatedDate ? '1px solid #8b5cf6' : '1px solid transparent',
background: selectedDate === group.generatedDate ? '#f5f3ff' : '#fff',
}}
>
<div style={{ height: 112, background: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{item.resourceType === 'image' ? (
<img src={preview} alt={item.fileName || item.id} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : item.resourceType === 'video' ? (
<video src={preview} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<div style={{ width: 58, height: 58, borderRadius: 18, background: 'linear-gradient(135deg, #8b5cf6, #a78bfa)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 24 }}>
<AudioOutlined />
<div style={{ fontSize: 13, fontWeight: 500, color: '#334155' }}>{group.generatedDate}</div>
<div style={{ fontSize: 12, color: '#94a3b8', marginTop: 4 }}> {group.total} </div>
</div>
))}
{totalDays > groups.length && (
<div style={{ marginTop: 12, textAlign: 'center' }}>
<Button size="small" onClick={() => setGroupPage(groupPage + 1)}></Button>
</div>
)}
</div>
)}
</Spin>
</div>
<div style={{ border: '1px solid #eef0f4', borderRadius: 12, padding: 12, overflowY: 'auto' }}>
<Spin spinning={loading}>
{!selectedDate ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="请选择日期" style={{ padding: '80px 0' }} />
) : items.length === 0 ? (
<Empty description="该日期暂无可复用的历史上传素材" style={{ padding: '48px 0' }} />
) : (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14 }}>
{items.map((item) => {
const checked = checkedMap.has(item.id);
const disabled = selectedIdSet.has(item.id);
const preview = buildPreviewUrl(item.previewUrl || item.displayUrl || item.resourceUrl);
return (
<div
key={item.id}
onClick={() => !disabled && toggle(item)}
style={{
position: 'relative',
borderRadius: 14,
border: checked ? '2px solid #8b5cf6' : '1px solid #e2e8f0',
background: disabled ? '#f8fafc' : '#fff',
opacity: disabled ? 0.55 : 1,
cursor: disabled ? 'not-allowed' : 'pointer',
overflow: 'hidden',
boxShadow: checked ? '0 10px 24px rgba(139, 92, 246, 0.18)' : '0 6px 18px rgba(15,23,42,0.06)',
}}
>
<div style={{ height: 112, background: '#f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{item.resourceType === 'image' ? (
<img src={preview} alt={item.fileName || item.id} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : item.resourceType === 'video' ? (
<video src={preview} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<div style={{ width: 58, height: 58, borderRadius: 18, background: 'linear-gradient(135deg, #8b5cf6, #a78bfa)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 24 }}>
<AudioOutlined />
</div>
)}
</div>
<div style={{ padding: 10 }}>
<Space size={6} style={{ marginBottom: 6 }}>
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
{item.durationSeconds ? <Tag style={{ margin: 0 }}>{Number(item.durationSeconds).toFixed(1)}s</Tag> : null}
</Space>
<Text ellipsis style={{ display: 'block', fontSize: 13, color: '#334155' }} title={item.fileName || item.id}>
{item.fileName || item.id}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{item.moduleLabel || '普通上传'}</Text>
</div>
{checked && (
<div style={{ position: 'absolute', top: 8, right: 8, width: 24, height: 24, borderRadius: 12, background: '#8b5cf6', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CheckOutlined />
</div>
)}
{disabled && <div style={{ position: 'absolute', top: 8, right: 8 }}><Tag></Tag></div>}
</div>
<div style={{ padding: 10 }}>
<Space size={6} style={{ marginBottom: 6 }}>
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
{item.durationSeconds ? <Tag style={{ margin: 0 }}>{Number(item.durationSeconds).toFixed(1)}s</Tag> : null}
</Space>
<Text ellipsis style={{ display: 'block', fontSize: 13, color: '#334155' }} title={item.fileName || item.id}>
{item.fileName || item.id}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{item.moduleLabel || '普通上传'}</Text>
</div>
{checked && (
<div style={{ position: 'absolute', top: 8, right: 8, width: 24, height: 24, borderRadius: 12, background: '#8b5cf6', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CheckOutlined />
</div>
)}
{disabled && <div style={{ position: 'absolute', top: 8, right: 8 }}><Tag></Tag></div>}
</div>
);
})}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16, paddingBottom: 8 }}>
<Pagination
current={page}
pageSize={pageSize}
total={total}
showSizeChanger
pageSizeOptions={[10, 20, 50, 100]}
onChange={(nextPage, nextSize) => {
setPage(nextPage);
setPageSize(nextSize);
}}
/>
</div>
</div>
)}
</Spin>
);
})}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16, paddingBottom: 8 }}>
<Pagination
current={itemPage}
pageSize={itemPageSize}
total={total}
showSizeChanger
pageSizeOptions={[10, 20, 50, 100]}
onChange={(nextPage, nextSize) => {
setItemPage(nextPage);
setItemPageSize(nextSize);
}}
/>
</div>
</>
)}
</Spin>
</div>
</div>
</Modal>
);
};
+97 -11
View File
@@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
// AI 创作对话框样式优化版 v12:白色极简高级风格,选项按钮、标题、头像与关键字统一使用 #8b5cf6。
@@ -124,6 +125,7 @@ const mockUpload = (file: File): Promise<{ url: string }> => {
const AIChatPage: React.FC = () => {
// ==================== 状态定义 ====================
const { message: antdMessage } = App.useApp();
const location = useLocation();
const [collapsed, setCollapsed] = useState<boolean>(false);
@@ -449,6 +451,32 @@ const AIChatPage: React.FC = () => {
const [isLoadingMore, setIsLoadingMore] = useState(false);
// ==================== 副作用 ====================
useEffect(() => {
const state = location.state as any;
if (state) {
if (state.generationPrompt) {
setInputValue(state.generationPrompt);
}
if (state.mediaReferences && Array.isArray(state.mediaReferences) && state.mediaReferences.length > 0) {
const mediaItems: MediaReference[] = state.mediaReferences.map((ref: any) => {
const url = ref.url || ref.resourceUrl || ref.previewUrl || '';
const fullUrl = url.startsWith('http') ? url : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${url}`;
return {
...ref,
name: ref.name || `参考${ref.type}`,
type: ref.type === 'video' ? 'video' : ref.type === 'audio' ? 'audio' : 'image',
url: fullUrl,
duration: ref.duration || 0,
label: ref.label || '',
};
});
setCurrentMedia(mediaItems);
}
window.history.replaceState({}, '', window.location.pathname);
}
}, [location.state]);
// 滚动到底部的辅助函数
useEffect(() => {
return () => {
@@ -1181,6 +1209,19 @@ const AIChatPage: React.FC = () => {
antdMessage.error('图片大小不能超过10MB');
return false;
}
try {
const { width, height } = await getImageDimensions(file);
const error = validateVideoDimensions(width, height);
if (error) {
antdMessage.error(error);
return false;
}
} catch {
antdMessage.error('无法读取图片尺寸,请检查文件是否损坏');
return false;
}
const effectiveUploadTarget = frameTarget || uploadTarget;
if (!effectiveUploadTarget) {
antdMessage.error('请选择首帧或尾帧上传位置');
@@ -1247,7 +1288,7 @@ const AIChatPage: React.FC = () => {
if (isImage) {
try {
const { width, height } = await getImageDimensions(file);
const error = validateImageDimensions(width, height);
const error = validateVideoDimensions(width, height);
if (error) {
antdMessage.error(error);
return false;
@@ -1331,8 +1372,14 @@ const AIChatPage: React.FC = () => {
setUploading(true);
try {
const uploadFn = isImage ? uploadImage : (isAudio ? uploadAudio : uploadVideo);
const res = await uploadFn(file);
let res;
if (isImage) {
res = await uploadImage(file);
} else if (isAudio) {
res = await uploadAudio(file, audioDuration);
} else {
res = await uploadVideo(file, videoDuration);
}
const mediaType: 'image' | 'video' | 'audio' = isImage ? 'image' : (isAudio ? 'audio' : 'video');
const newList = [...currentMedia, {
name: file.name,
@@ -1377,6 +1424,21 @@ const AIChatPage: React.FC = () => {
}
}
// 图片尺寸校验
if (isImage) {
try {
const { width, height } = await getImageDimensions(file);
const error = validateVideoDimensions(width, height);
if (error) {
antdMessage.error(error);
return false;
}
} catch {
antdMessage.error('无法读取图片尺寸,请检查文件是否损坏');
return false;
}
}
let videoDuration = 0;
let audioDuration = 0;
if (isVideo) {
@@ -1436,8 +1498,14 @@ const AIChatPage: React.FC = () => {
}
try {
const uploadFn = isImage ? uploadImage : (isAudio ? uploadAudio : uploadVideo);
const res = await uploadFn(file);
let res;
if (isImage) {
res = await uploadImage(file);
} else if (isAudio) {
res = await uploadAudio(file, audioDuration);
} else {
res = await uploadVideo(file, videoDuration);
}
return {
name: pendingMedia.name,
type: pendingMedia.type,
@@ -1455,9 +1523,9 @@ const AIChatPage: React.FC = () => {
let successCount = 0;
let failCount = 0;
setUploading(true);
for (const file of files) {
setUploading(true);
const result = await doUpload(file);
if (result) {
@@ -1469,10 +1537,10 @@ const AIChatPage: React.FC = () => {
} else {
failCount++;
}
setUploading(false);
}
setUploading(false);
if (successCount > 0) {
antdMessage.success(`成功上传${successCount}个文件${failCount > 0 ? `${failCount}个文件上传失败` : ''}`);
}
@@ -2781,6 +2849,16 @@ const AIChatPage: React.FC = () => {
视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}${maxAudio > 0 ? `
音频${currentMedia.filter(m => m.type === 'audio').length}/${maxAudio}` : ''}`
}
maxImageCount={maxImageCount}
maxVideoCount={maxVideoCount}
usedImageCount={currentMedia.filter(m => m.type === 'image').length}
usedVideoCount={currentMedia.filter(m => m.type === 'video').length}
usedVideoDuration={currentMedia.filter(m => m.type === 'video').reduce((sum, m) => sum + (m.duration || 0), 0)}
maxVideoDuration={15}
maxAudioCount={maxAudio > 0 ? maxAudio : undefined}
usedAudioCount={currentMedia.filter(m => m.type === 'audio').length}
maxAudioDuration={15}
usedAudioDuration={currentMedia.filter(m => m.type === 'audio').reduce((sum, m) => sum + (m.duration || 0), 0)}
>
<div
style={{
@@ -2952,6 +3030,16 @@ const AIChatPage: React.FC = () => {
视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}${maxAudio > 0 ? `
音频${currentMedia.filter(m => m.type === 'audio').length}/${maxAudio}` : ''}`
}
maxImageCount={maxImageCount}
maxVideoCount={maxVideoCount}
usedImageCount={currentMedia.filter(m => m.type === 'image').length}
usedVideoCount={currentMedia.filter(m => m.type === 'video').length}
usedVideoDuration={currentMedia.filter(m => m.type === 'video').reduce((sum, m) => sum + (m.duration || 0), 0)}
maxVideoDuration={15}
maxAudioCount={maxAudio > 0 ? maxAudio : undefined}
usedAudioCount={currentMedia.filter(m => m.type === 'audio').length}
maxAudioDuration={15}
usedAudioDuration={currentMedia.filter(m => m.type === 'audio').reduce((sum, m) => sum + (m.duration || 0), 0)}
>
<div
style={{
@@ -3415,8 +3503,6 @@ const AIChatPage: React.FC = () => {
}
}
setShowEngineModal(false);
setCurrentMedia([]);
setInputValue('');
}}
style={{
flex: 1,
+59 -26
View File
@@ -318,9 +318,13 @@ const GeneratePage: React.FC = () => {
}
}
setUploading(true);
const uploadFn = isImage ? uploadImage : uploadVideo;
try {
const res = await uploadFn(file);
let res;
if (isImage) {
res = await uploadImage(file);
} else {
res = await uploadVideo(file, fileDuration);
}
const typeLabel = isImage ? "图片" : "视频";
const typeCount = isImage
? imageCount + 1
@@ -4016,31 +4020,60 @@ const GeneratePage: React.FC = () => {
message.error("仅支持图片或视频文件");
return false;
}
const uploadFn = isImage
? uploadImage
: uploadVideo;
uploadFn(file)
.then((res) => {
const newRef = {
url: res.url,
type: isImage
? ("image" as const)
: ("video" as const),
name:
file.name ||
(isImage ? "图片" : "视频"),
if (isImage) {
uploadImage(file)
.then((res) => {
const newRef = {
url: res.url,
type: "image" as const,
name: file.name || "图片",
};
const curRefs =
record.references || [];
useAppStore
.getState()
.updateRecordReferences(record.id, [
...curRefs,
newRef,
]);
message.success("上传成功");
})
.catch(() => message.error("上传失败"));
} else {
new Promise<number>((resolve) => {
const video = document.createElement("video");
video.preload = "metadata";
video.onloadedmetadata = () => {
resolve(video.duration || 0);
video.remove();
};
const curRefs =
record.references || [];
useAppStore
.getState()
.updateRecordReferences(record.id, [
...curRefs,
newRef,
]);
message.success("上传成功");
})
.catch(() => message.error("上传失败"));
video.onerror = () => {
resolve(0);
video.remove();
};
video.src = URL.createObjectURL(file);
}).then((fileDuration) => {
uploadVideo(file, fileDuration)
.then((res) => {
const newRef = {
url: res.url,
type: "video" as const,
name: file.name || "视频",
duration: fileDuration || undefined,
};
const curRefs =
record.references || [];
useAppStore
.getState()
.updateRecordReferences(record.id, [
...curRefs,
newRef,
]);
message.success("上传成功");
})
.catch(() => message.error("上传失败"));
});
}
return false;
}}
>
+153 -30
View File
@@ -8,6 +8,8 @@ import {
UploadOutlined,
VideoCameraOutlined,
PictureOutlined,
AudioOutlined,
PauseOutlined,
ThunderboltOutlined,
PlayCircleOutlined,
HeartOutlined,
@@ -47,6 +49,9 @@ const HomePage: React.FC = () => {
const [previewAsset, setPreviewAsset] = useState<any>(null);
const previewVideoRef = useRef<HTMLVideoElement>(null);
const [activeContentTab, setActiveContentTab] = useState<'works' | 'cases'>('cases');
const [playingVideoIndex, setPlayingVideoIndex] = useState<number | null>(null);
const [playingAudioUrl, setPlayingAudioUrl] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
getHomeCaseHeader().then((res: any) => {
@@ -66,6 +71,36 @@ const HomePage: React.FC = () => {
});
}, []);
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
setPlayingAudioUrl(null);
setPlayingVideoIndex(null);
const allVideos = document.querySelectorAll('.preview-video-item');
allVideos.forEach((v) => {
(v as HTMLVideoElement).pause();
});
};
}, []);
useEffect(() => {
if (!previewAsset) {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
setPlayingAudioUrl(null);
setPlayingVideoIndex(null);
const allVideos = document.querySelectorAll('.preview-video-item');
allVideos.forEach((v) => {
(v as HTMLVideoElement).pause();
});
}
}, [previewAsset]);
useEffect(() => {
const fetchAll = async () => {
try {
@@ -972,44 +1007,125 @@ const HomePage: React.FC = () => {
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#000000ff', marginBottom: 8 }}></div>
<div style={{ fontSize: 13, color: '#000000ff', lineHeight: 1.6 }}>
{previewAsset?.prompt || '动态描述:女性抬手整理头发,随后手持口服液用手指向产品讲解;画面切换为双手将口服液中的棕黄色液体缓缓倒入透明玻璃杯;再次切换女性讲解画面,双手做出展示动作指向产品;最后双手在胸前做出托手姿势后摊开手掌微笑描述:女性讲解的...'}
{previewAsset?.generationPrompt }
</div>
</div>
{/* 视频参考图 */}
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#94a3b8', marginBottom: 8 }}></div>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ width: 80, height: 80, borderRadius: 8, overflow: 'hidden', cursor: 'pointer' }}>
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset?.url}`}
alt="参考图"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</div>
<div style={{
width: 80,
height: 80,
borderRadius: 8,
border: '1px dashed #475569',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#64748b',
fontSize: 12,
}}>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{previewAsset?.mediaReferences && previewAsset.mediaReferences.length > 0 ? (
previewAsset.mediaReferences.map((ref: any, index: number) => {
const url = ref.url || ref.resourceUrl || ref.previewUrl || '';
const fullUrl = url.startsWith('http') ? url : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${url}`;
if (ref.type === 'video') {
return (
<div key={index} style={{ width: 80, height: 80, borderRadius: 8, overflow: 'hidden', cursor: 'pointer', position: 'relative' }}>
<video
src={fullUrl}
muted
onClick={(e) => {
e.stopPropagation();
const video = e.currentTarget as HTMLVideoElement;
if (playingVideoIndex === index) {
video.pause();
setPlayingVideoIndex(null);
} else {
const allVideos = document.querySelectorAll('.preview-video-item');
allVideos.forEach((v) => {
(v as HTMLVideoElement).pause();
});
video.play();
setPlayingVideoIndex(index);
}
}}
className="preview-video-item"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
{playingVideoIndex !== index && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.3)' }}>
<VideoCameraOutlined style={{ fontSize: 18, color: '#fff' }} />
</div>
)}
</div>
);
} else if (ref.type === 'audio') {
return (
<div key={index} onClick={(e) => {
e.stopPropagation();
if (playingAudioUrl === fullUrl) {
if (audioRef.current) {
audioRef.current.pause();
}
setPlayingAudioUrl(null);
} else {
if (audioRef.current) {
audioRef.current.pause();
}
const audio = new Audio(fullUrl);
audioRef.current = audio;
audio.onended = () => {
setPlayingAudioUrl(null);
};
audio.play();
setPlayingAudioUrl(fullUrl);
}
}} style={{ width: 80, height: 80, borderRadius: 8, overflow: 'hidden', cursor: 'pointer', background: 'linear-gradient(135deg, #8b5cf6, #a78bfa)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{playingAudioUrl === fullUrl ? (
<PauseOutlined style={{ fontSize: 24, color: '#fff' }} />
) : (
<AudioOutlined style={{ fontSize: 24, color: '#fff' }} />
)}
</div>
);
} else {
return (
<div key={index} style={{ width: 80, height: 80, borderRadius: 8, overflow: 'hidden', cursor: 'pointer' }}>
<img
src={fullUrl}
alt={`参考图 ${index + 1}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</div>
);
}
})
) : (
<>
{/* <div style={{ width: 80, height: 80, borderRadius: 8, overflow: 'hidden', cursor: 'pointer' }}>
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset?.url}`}
alt="参考图"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</div>
<div style={{
width: 80,
height: 80,
borderRadius: 8,
border: '1px dashed #475569',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#64748b',
fontSize: 12,
}}>
展开查看
</div> */}
</>
)}
</div>
</div>
{/* 口播脚本台词 */}
<div style={{ marginBottom: 16 }}>
{/* <div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#000000ff', marginBottom: 8 }}>口播脚本台词</div>
<div style={{ fontSize: 13, color: '#000000ff', lineHeight: 1.6 }}>
{previewAsset?.script || '还在为宝宝不爱喝水发愁?试试这款天然果蔬汁!零添加糖分,维生素满满,口感清甜宝宝超爱喝。现在下单还送专属吸管杯,手慢无!点下方链接把健康带回家~'}
</div>
{/* <div style={{
<div style={{
fontSize: 12,
color: '#6366f1',
marginTop: 8,
@@ -1019,8 +1135,8 @@ const HomePage: React.FC = () => {
gap: 4,
}}>
完整内容女声
</div> */}
</div>
</div>
</div> */}
{/* 视频标签 */}
{/* <div style={{ marginBottom: 16 }}>
@@ -1118,9 +1234,16 @@ const HomePage: React.FC = () => {
</div> */}
<div style={{ display: 'flex', gap: 12 }}>
{previewAsset?.mediaType !== 'video' && (
{/* {previewAsset?.mediaType !== 'video' && ( */}
<Button
// onClick={() => navigate('/generate')}
onClick={() => {
navigate('/conversation', {
state: {
generationPrompt: previewAsset?.generationPrompt,
mediaReferences: previewAsset?.mediaReferences,
},
});
}}
style={{
padding: '8px 24px',
borderRadius: 8,
@@ -1133,7 +1256,7 @@ const HomePage: React.FC = () => {
>
AI创作
</Button>
)}
{/* )}
<Button
// onClick={() => navigate('/initial')}
style={{
@@ -1147,7 +1270,7 @@ const HomePage: React.FC = () => {
}}
>
去爆款复刻
</Button>
</Button> */}
</div>
</div>
</Modal>
+23 -4
View File
@@ -3,7 +3,7 @@ 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 {uploadShotReplicateImage, createRemoveLens, deleteSegment, getShotReplicationDetail, reanalyzeSegment, Removelist, removeCreate, reanalyzeShotReplication, splitCustom, uploadImage } from '../api';
import {uploadShotReplicateImage, createRemoveLens, deleteSegment, getShotReplicationDetail, reanalyzeSegment, retrySplit, Removelist, removeCreate, reanalyzeShotReplication, splitCustom, uploadImage } from '../api';
import VideoTrimPicker from '../components/VideoTrimPicker';
@@ -169,6 +169,16 @@ function RemoveInfo() {
}
}, [fetchSegments]);
const handleRetrySplit = useCallback(async (segmentId: string) => {
try {
await retrySplit(segmentId);
message.success('重新切割已提交');
fetchSegments();
} catch (error: any) {
message.error(error?.message || '重新切割失败');
}
}, [fetchSegments]);
const handleDeleteSegment = useCallback(async (segmentId: string) => {
try {
await deleteSegment(segmentId);
@@ -589,11 +599,11 @@ function RemoveInfo() {
},
{
title: '操作',
width: 140,
width: 200,
fixed: 'right',
align: 'center' as const,
render: (_: any, record: any) => (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{record.moduleProjectId ? (
<Button
type="text"
@@ -616,11 +626,20 @@ function RemoveInfo() {
<Button
type="text"
onClick={() => handleSegmentReanalyze(String(record.id))}
style={{ color: '#ef4444', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
style={{ color: '#f89411ff', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
>
</Button>
)}
{record.splitStatus === 'failed' && (
<Button
type="text"
onClick={() => handleRetrySplit(String(record.id))}
style={{ color: '#f89411ff', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
>
</Button>
)}
<Popconfirm
title="确定删除此片段?"
onConfirm={() => handleDeleteSegment(String(record.id))}