This commit is contained in:
2026-07-10 09:20:24 +08:00
13 changed files with 670 additions and 242 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-CM_dxMTI.js"></script> <script type="module" crossorigin src="/assets/index-epOb7vts.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-JhRVnnL-.css"> <link rel="stylesheet" crossorigin href="/assets/index-JhRVnnL-.css">
</head> </head>
<body> <body>
+5
View File
@@ -682,6 +682,11 @@ export async function deleteSegment(segmentId: string): Promise<void> {
export async function reanalyzeSegment(segmentId: string): Promise<any> { export async function reanalyzeSegment(segmentId: string): Promise<any> {
return api.post(`/shot-replications/segments/${segmentId}/reanalyze`); 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> { export async function deleteShotReplicationProject(taskSetId: string): Promise<void> {
await api.delete(`/shot-replications/task-sets/${taskSetId}`); await api.delete(`/shot-replications/task-sets/${taskSetId}`);
@@ -26,6 +26,10 @@ interface UploadSelectorProps {
usedVideoCount?: number; usedVideoCount?: number;
usedVideoDuration?: number; usedVideoDuration?: number;
maxVideoDuration?: number; maxVideoDuration?: number;
maxAudioCount?: number;
usedAudioCount?: number;
maxAudioDuration?: number;
usedAudioDuration?: number;
} }
const UploadSelector: React.FC<UploadSelectorProps> = ({ const UploadSelector: React.FC<UploadSelectorProps> = ({
@@ -44,12 +48,17 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
usedVideoCount, usedVideoCount,
usedVideoDuration, usedVideoDuration,
maxVideoDuration, maxVideoDuration,
maxAudioCount,
usedAudioCount,
maxAudioDuration,
usedAudioDuration,
}) => { }) => {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [portraitPickerOpen, setPortraitPickerOpen] = useState(false); const [portraitPickerOpen, setPortraitPickerOpen] = useState(false);
const [portraitLibraryType, setPortraitLibraryType] = useState<PrivatePortraitLibraryType>('real_person'); const [portraitLibraryType, setPortraitLibraryType] = useState<PrivatePortraitLibraryType>('real_person');
const [historyModalVisible, setHistoryModalVisible] = useState(false); const [historyModalVisible, setHistoryModalVisible] = useState(false);
const [popoverOpen, setPopoverOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const handleLocalSelect = () => { const handleLocalSelect = () => {
setPopoverOpen(false); setPopoverOpen(false);
@@ -224,6 +233,16 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
setHistoryModalVisible(false); setHistoryModalVisible(false);
}} }}
allowedTypes={accept.includes('audio') ? ['image', 'video', 'audio'] : accept.includes('video') ? ['image', 'video'] : ['image']} allowedTypes={accept.includes('audio') ? ['image', 'video', 'audio'] : accept.includes('video') ? ['image', 'video'] : ['image']}
maxImageCount={maxImageCount}
maxVideoCount={maxVideoCount}
usedImageCount={usedImageCount}
usedVideoCount={usedVideoCount}
maxVideoDuration={maxVideoDuration}
usedVideoDuration={usedVideoDuration}
maxAudioCount={maxAudioCount}
usedAudioCount={usedAudioCount}
maxAudioDuration={maxAudioDuration}
usedAudioDuration={usedAudioDuration}
/> />
<PrivatePortraitAssetPicker <PrivatePortraitAssetPicker
@@ -84,7 +84,7 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
}; };
return ( return (
<div style={{ padding: 16 }}> <div>
{/* <div style={{ marginBottom: 16 }}> {/* <div style={{ marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}>私域素材库</Title> <Title level={4} style={{ margin: 0 }}>私域素材库</Title>
<Text type="secondary">统一管理真人素材和虚拟素材。真人素材需先完成人脸认证后才可上传素材,虚拟素材如需使用需先上传。</Text> <Text type="secondary">统一管理真人素材和虚拟素材。真人素材需先完成人脸认证后才可上传素材,虚拟素材如需使用需先上传。</Text>
@@ -117,15 +117,17 @@ const RealPersonLibraryPanel: React.FC = () => {
return ( return (
<div> <div>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary"></Typography.Text>
</div> </div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>
</div>
<Row gutter={16}> <Row gutter={16}>
<Col xs={24} lg={7}> <Col xs={24} lg={5}>
<Card <Card
// title="真人素材项目组" // title="真人素材项目组"
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>}
style={{ borderRadius: 16, minHeight: 520 }} style={{ borderRadius: 16, minHeight: 520 }}
> >
<Spin spinning={loading}> <Spin spinning={loading}>
@@ -137,7 +139,7 @@ const RealPersonLibraryPanel: React.FC = () => {
</Spin> </Spin>
</Card> </Card>
</Col> </Col>
<Col xs={24} lg={17}> <Col xs={24} lg={19}>
{selected ? ( {selected ? (
<PrivatePortraitProjectDetail project={selected} onDeleted={() => { setSelected(null); loadProjects(); }} onChanged={loadProjects} /> <PrivatePortraitProjectDetail project={selected} onDeleted={() => { setSelected(null); loadProjects(); }} onChanged={loadProjects} />
) : ( ) : (
@@ -387,15 +387,17 @@ const VirtualMaterialPanel: React.FC = () => {
return ( return (
<div > <div >
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">使</Typography.Text> <Typography.Text type="secondary">使</Typography.Text>
</div> </div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>
</div>
<Row gutter={[10, 10]}> <Row gutter={[10, 10]}>
<Col xs={24} lg={5} > <Col xs={24} lg={5} >
<Card <Card
// title="虚拟人像项目组" // title="虚拟人像项目组"
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>}
style={{ borderRadius: 16, minHeight: 520, margin: '0 auto', maxWidth: 1200}} style={{ borderRadius: 16, minHeight: 520, margin: '0 auto', maxWidth: 1200}}
> >
<Spin spinning={projectLoading}> <Spin spinning={projectLoading}>
@@ -44,6 +44,7 @@ const UploadResourceHistoryPanel: React.FC = () => {
const [totalDays, setTotalDays] = useState(0); const [totalDays, setTotalDays] = useState(0);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [previewItem, setPreviewItem] = useState<UploadResourceHistoryItem | null>(null); const [previewItem, setPreviewItem] = useState<UploadResourceHistoryItem | null>(null);
const [batchMode, setBatchMode] = useState(false);
const allItems = useMemo(() => groups.flatMap((group) => group.items || []), [groups]); const allItems = useMemo(() => groups.flatMap((group) => group.items || []), [groups]);
@@ -199,11 +200,18 @@ const UploadResourceHistoryPanel: React.FC = () => {
load(); load();
}} }}
/> />
<Button icon={<ReloadOutlined />} onClick={load}></Button> {/* <Button icon={<ReloadOutlined />} onClick={load}>刷新</Button> */}
</Space> </Space>
<Space> <Space>
{batchMode ? (
<>
<Button onClick={selectAll}>{selectedIds.size && selectedIds.size === allItems.length ? '取消全选' : '全选'}</Button> <Button onClick={selectAll}>{selectedIds.size && selectedIds.size === allItems.length ? '取消全选' : '全选'}</Button>
<Button danger icon={<DeleteOutlined />} onClick={handleBatchDelete}> ({selectedIds.size})</Button> <Button danger icon={<DeleteOutlined />} onClick={handleBatchDelete}> ({selectedIds.size})</Button>
<Button onClick={() => { setBatchMode(false); setSelectedIds(new Set()); }}></Button>
</>
) : (
<Button onClick={() => setBatchMode(true)}></Button>
)}
</Space> </Space>
</div> </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 key={item.id} style={{ width: '17%', minWidth: 240, margin: 16, border: checked ? '2px solid #8b5cf6' : '1px solid #e2e8f0', borderRadius: 16, overflow: 'hidden', background: '#fff', boxShadow: '0 10px 24px rgba(15,23,42,0.06)' }}>
<div style={{ position: 'relative', background: '#f1f5f9' }}> <div style={{ position: 'relative', background: '#f1f5f9' }}>
{renderMedia(item)} {renderMedia(item)}
<Checkbox checked={checked} onChange={() => toggle(item.id)} style={{ position: 'absolute', top: 10, left: 10, background: '#fff', borderRadius: 6, padding: 4 }} /> {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> <Tag color="purple" icon={typeIcon(item.resourceType)} style={{ position: 'absolute', top: 10, right: 10, margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
</div> </div>
<div style={{ padding: 12 ,paddingTop: 0}}> <div style={{ padding: 12 ,paddingTop: 0}}>
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { AudioOutlined, CheckOutlined, PictureOutlined, ReloadOutlined, SearchOutlined, VideoCameraOutlined } from '@ant-design/icons'; 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 { 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'; import type { UploadResourceHistoryItem } from '../../types';
const { Text } = Typography; const { Text } = Typography;
@@ -15,10 +15,18 @@ interface UploadResourceHistoryPickerProps {
allowedTypes?: Array<'image' | 'video' | 'audio'>; allowedTypes?: Array<'image' | 'video' | 'audio'>;
selectedIds?: string[]; selectedIds?: string[];
title?: 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) => { const buildPreviewUrl = (url: string) => {
if (!url) return ''; if (!url) return '';
if (/^(https?:|data:|blob:)/i.test(url)) return url; if (/^(https?:|data:|blob:)/i.test(url)) return url;
@@ -45,29 +53,93 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
allowedTypes, allowedTypes,
selectedIds = [], selectedIds = [],
title = '选择历史上传素材', title = '选择历史上传素材',
maxImageCount,
maxVideoCount,
usedImageCount,
usedVideoCount,
maxVideoDuration,
usedVideoDuration,
maxAudioCount,
usedAudioCount,
maxAudioDuration,
usedAudioDuration,
}) => { }) => {
const [date, setDate] = useState(today());
const [resourceType, setResourceType] = useState<MediaTypeFilter>(''); const [resourceType, setResourceType] = useState<MediaTypeFilter>('');
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(1); const [groupPage, setGroupPage] = useState(1);
const [pageSize, setPageSize] = useState(20); const [groupPageSize, setGroupPageSize] = useState(10);
const [itemPage, setItemPage] = useState(1);
const [itemPageSize, setItemPageSize] = useState(10);
const [loading, setLoading] = useState(false); 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 [items, setItems] = useState<UploadResourceHistoryItem[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [checkedMap, setCheckedMap] = useState<Map<string, UploadResourceHistoryItem>>(new Map()); const [checkedMap, setCheckedMap] = useState<Map<string, UploadResourceHistoryItem>>(new Map());
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]); 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; 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); setLoading(true);
try { try {
const res = await getUploadResourceHistoryItems(date, { const res = await getUploadResourceHistoryItems(date, {
resourceType: resourceType || undefined, resourceType: resourceType || undefined,
keyword: keyword || undefined, keyword: keyword || undefined,
page, page: itemPage,
pageSize, pageSize: 8,
scene: 'picker', scene: 'picker',
}); });
const filtered = (res.items || []).filter((item) => effectiveAllowedTypes.has(item.resourceType)); const filtered = (res.items || []).filter((item) => effectiveAllowedTypes.has(item.resourceType));
@@ -80,11 +152,23 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [date, effectiveAllowedTypes, keyword, open, page, pageSize, resourceType]); }, [effectiveAllowedTypes, keyword, itemPage, itemPageSize, resourceType]);
useEffect(() => { useEffect(() => {
if (open) load(); if (open) loadGroups();
}, [open, load]); }, [open, groupPage, loadGroups]);
useEffect(() => {
if (selectedDate) {
setItemPage(1);
}
}, [selectedDate]);
useEffect(() => {
if (selectedDate) {
loadItems(selectedDate);
}
}, [selectedDate, itemPage, loadItems]);
useEffect(() => { useEffect(() => {
if (open) setCheckedMap(new Map()); if (open) setCheckedMap(new Map());
@@ -129,33 +213,25 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
title={title} title={title}
open={open} open={open}
onCancel={onClose} onCancel={onClose}
width={920} width={1000}
centered centered
destroyOnHidden destroyOnHidden
footer={[ footer={[
<Button key="cancel" onClick={onClose}></Button>, <Button key="cancel" onClick={onClose}></Button>,
<Button key="ok" type="primary" onClick={confirm} style={{ background: '#8b5cf6' }}> <Button key="ok" type="primary" onClick={confirm} style={{ background: '#8b5cf6' }} disabled={imageExceeded || videoExceeded || durationExceeded || audioExceeded || audioDurationExceeded}>
({checkedMap.size}) ({checkedMap.size})
</Button>, </Button>,
]} ]}
> >
<Space wrap style={{ width: '100%', marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<Input <Space wrap>
type="date"
value={date}
onChange={(e) => {
setDate(e.target.value || today());
setPage(1);
}}
style={{ width: 160 }}
/>
<Select <Select
value={resourceType} value={resourceType}
options={typeOptions} options={typeOptions}
style={{ width: 120 }} style={{ width: 120 }}
onChange={(value) => { onChange={(value) => {
setResourceType(value); setResourceType(value);
setPage(1); setGroupPage(1);
}} }}
/> />
<Input <Input
@@ -166,18 +242,70 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
style={{ width: 220 }} style={{ width: 220 }}
onChange={(e) => setKeyword(e.target.value)} onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => { onPressEnter={() => {
setPage(1); setGroupPage(1);
load(); loadGroups();
}} }}
/> />
<Button icon={<ReloadOutlined />} onClick={load}></Button> <Button icon={<ReloadOutlined />} onClick={loadGroups}></Button>
</Space> </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}> <div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 16, height: 500 }}>
{items.length === 0 ? ( <div style={{ border: '1px solid #eef0f4', borderRadius: 12, padding: 12, background: '#fafafa', overflowY: 'auto' }}>
<Empty description="暂无可复用的历史上传素材" style={{ padding: '48px 0' }} /> <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={{ height: '500px', overflowY: 'auto' }}> <div style={{ marginTop: 12 }}>
{groups.map((group) => (
<div
key={group.generatedDate}
onClick={() => setSelectedDate(group.generatedDate)}
style={{
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={{ 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 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14 }}>
{items.map((item) => { {items.map((item) => {
const checked = checkedMap.has(item.id); const checked = checkedMap.has(item.id);
@@ -219,6 +347,7 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
</Text> </Text>
<Text type="secondary" style={{ fontSize: 12 }}>{item.moduleLabel || '普通上传'}</Text> <Text type="secondary" style={{ fontSize: 12 }}>{item.moduleLabel || '普通上传'}</Text>
</div> </div>
{checked && ( {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' }}> <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 /> <CheckOutlined />
@@ -231,20 +360,22 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
</div> </div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16, paddingBottom: 8 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16, paddingBottom: 8 }}>
<Pagination <Pagination
current={page} current={itemPage}
pageSize={pageSize} pageSize={itemPageSize}
total={total} total={total}
showSizeChanger showSizeChanger
pageSizeOptions={[10, 20, 50, 100]} pageSizeOptions={[10, 20, 50, 100]}
onChange={(nextPage, nextSize) => { onChange={(nextPage, nextSize) => {
setPage(nextPage); setItemPage(nextPage);
setPageSize(nextSize); setItemPageSize(nextSize);
}} }}
/> />
</div> </div>
</div> </>
)} )}
</Spin> </Spin>
</div>
</div>
</Modal> </Modal>
); );
}; };
+93 -7
View File
@@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
// AI 创作对话框样式优化版 v12:白色极简高级风格,选项按钮、标题、头像与关键字统一使用 #8b5cf6。 // AI 创作对话框样式优化版 v12:白色极简高级风格,选项按钮、标题、头像与关键字统一使用 #8b5cf6。
@@ -124,6 +125,7 @@ const mockUpload = (file: File): Promise<{ url: string }> => {
const AIChatPage: React.FC = () => { const AIChatPage: React.FC = () => {
// ==================== 状态定义 ==================== // ==================== 状态定义 ====================
const { message: antdMessage } = App.useApp(); const { message: antdMessage } = App.useApp();
const location = useLocation();
const [collapsed, setCollapsed] = useState<boolean>(false); const [collapsed, setCollapsed] = useState<boolean>(false);
@@ -474,6 +476,32 @@ const AIChatPage: React.FC = () => {
const [isLoadingMore, setIsLoadingMore] = useState(false); 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(() => { useEffect(() => {
return () => { return () => {
@@ -1206,6 +1234,19 @@ const AIChatPage: React.FC = () => {
antdMessage.error('图片大小不能超过10MB'); antdMessage.error('图片大小不能超过10MB');
return false; 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; const effectiveUploadTarget = frameTarget || uploadTarget;
if (!effectiveUploadTarget) { if (!effectiveUploadTarget) {
antdMessage.error('请选择首帧或尾帧上传位置'); antdMessage.error('请选择首帧或尾帧上传位置');
@@ -1272,7 +1313,7 @@ const AIChatPage: React.FC = () => {
if (isImage) { if (isImage) {
try { try {
const { width, height } = await getImageDimensions(file); const { width, height } = await getImageDimensions(file);
const error = validateImageDimensions(width, height); const error = validateVideoDimensions(width, height);
if (error) { if (error) {
antdMessage.error(error); antdMessage.error(error);
return false; return false;
@@ -1356,8 +1397,14 @@ const AIChatPage: React.FC = () => {
setUploading(true); setUploading(true);
try { try {
const uploadFn = isImage ? uploadImage : (isAudio ? uploadAudio : uploadVideo); let res;
const res = await uploadFn(file); 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 mediaType: 'image' | 'video' | 'audio' = isImage ? 'image' : (isAudio ? 'audio' : 'video');
const newList = [...currentMedia, { const newList = [...currentMedia, {
name: file.name, name: file.name,
@@ -1402,6 +1449,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 videoDuration = 0;
let audioDuration = 0; let audioDuration = 0;
if (isVideo) { if (isVideo) {
@@ -1461,8 +1523,14 @@ const AIChatPage: React.FC = () => {
} }
try { try {
const uploadFn = isImage ? uploadImage : (isAudio ? uploadAudio : uploadVideo); let res;
const res = await uploadFn(file); if (isImage) {
res = await uploadImage(file);
} else if (isAudio) {
res = await uploadAudio(file, audioDuration);
} else {
res = await uploadVideo(file, videoDuration);
}
return { return {
name: pendingMedia.name, name: pendingMedia.name,
type: pendingMedia.type, type: pendingMedia.type,
@@ -2806,6 +2874,16 @@ const AIChatPage: React.FC = () => {
视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}${maxAudio > 0 ? ` 视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}${maxAudio > 0 ? `
音频${currentMedia.filter(m => m.type === 'audio').length}/${maxAudio}` : ''}` 音频${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 <div
style={{ style={{
@@ -2977,6 +3055,16 @@ const AIChatPage: React.FC = () => {
视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}${maxAudio > 0 ? ` 视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}${maxAudio > 0 ? `
音频${currentMedia.filter(m => m.type === 'audio').length}/${maxAudio}` : ''}` 音频${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 <div
style={{ style={{
@@ -3440,8 +3528,6 @@ const AIChatPage: React.FC = () => {
} }
} }
setShowEngineModal(false); setShowEngineModal(false);
setCurrentMedia([]);
setInputValue('');
}} }}
style={{ style={{
flex: 1, flex: 1,
+45 -12
View File
@@ -318,9 +318,13 @@ const GeneratePage: React.FC = () => {
} }
} }
setUploading(true); setUploading(true);
const uploadFn = isImage ? uploadImage : uploadVideo;
try { try {
const res = await uploadFn(file); let res;
if (isImage) {
res = await uploadImage(file);
} else {
res = await uploadVideo(file, fileDuration);
}
const typeLabel = isImage ? "图片" : "视频"; const typeLabel = isImage ? "图片" : "视频";
const typeCount = isImage const typeCount = isImage
? imageCount + 1 ? imageCount + 1
@@ -4016,19 +4020,13 @@ const GeneratePage: React.FC = () => {
message.error("仅支持图片或视频文件"); message.error("仅支持图片或视频文件");
return false; return false;
} }
const uploadFn = isImage if (isImage) {
? uploadImage uploadImage(file)
: uploadVideo;
uploadFn(file)
.then((res) => { .then((res) => {
const newRef = { const newRef = {
url: res.url, url: res.url,
type: isImage type: "image" as const,
? ("image" as const) name: file.name || "图片",
: ("video" as const),
name:
file.name ||
(isImage ? "图片" : "视频"),
}; };
const curRefs = const curRefs =
record.references || []; record.references || [];
@@ -4041,6 +4039,41 @@ const GeneratePage: React.FC = () => {
message.success("上传成功"); message.success("上传成功");
}) })
.catch(() => message.error("上传失败")); .catch(() => message.error("上传失败"));
} else {
new Promise<number>((resolve) => {
const video = document.createElement("video");
video.preload = "metadata";
video.onloadedmetadata = () => {
resolve(video.duration || 0);
video.remove();
};
video.onerror = () => {
resolve(0);
video.remove();
};
video.src = URL.createObjectURL(file);
}).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; return false;
}} }}
> >
+134 -11
View File
@@ -8,6 +8,8 @@ import {
UploadOutlined, UploadOutlined,
VideoCameraOutlined, VideoCameraOutlined,
PictureOutlined, PictureOutlined,
AudioOutlined,
PauseOutlined,
ThunderboltOutlined, ThunderboltOutlined,
PlayCircleOutlined, PlayCircleOutlined,
HeartOutlined, HeartOutlined,
@@ -47,6 +49,9 @@ const HomePage: React.FC = () => {
const [previewAsset, setPreviewAsset] = useState<any>(null); const [previewAsset, setPreviewAsset] = useState<any>(null);
const previewVideoRef = useRef<HTMLVideoElement>(null); const previewVideoRef = useRef<HTMLVideoElement>(null);
const [activeContentTab, setActiveContentTab] = useState<'works' | 'cases'>('cases'); 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(() => { useEffect(() => {
getHomeCaseHeader().then((res: any) => { 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(() => { useEffect(() => {
const fetchAll = async () => { const fetchAll = async () => {
try { try {
@@ -972,15 +1007,94 @@ const HomePage: React.FC = () => {
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#000000ff', marginBottom: 8 }}></div> <div style={{ fontSize: 12, color: '#000000ff', marginBottom: 8 }}></div>
<div style={{ fontSize: 13, color: '#000000ff', lineHeight: 1.6 }}> <div style={{ fontSize: 13, color: '#000000ff', lineHeight: 1.6 }}>
{previewAsset?.prompt || '动态描述:女性抬手整理头发,随后手持口服液用手指向产品讲解;画面切换为双手将口服液中的棕黄色液体缓缓倒入透明玻璃杯;再次切换女性讲解画面,双手做出展示动作指向产品;最后双手在胸前做出托手姿势后摊开手掌微笑描述:女性讲解的...'} {previewAsset?.generationPrompt }
</div> </div>
</div> </div>
{/* 视频参考图 */} {/* 视频参考图 */}
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#94a3b8', marginBottom: 8 }}></div> <div style={{ fontSize: 12, color: '#94a3b8', marginBottom: 8 }}></div>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<div style={{ width: 80, height: 80, borderRadius: 8, overflow: 'hidden', cursor: 'pointer' }}> {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 <img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset?.url}`} src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset?.url}`}
alt="参考图" alt="参考图"
@@ -999,17 +1113,19 @@ const HomePage: React.FC = () => {
fontSize: 12, fontSize: 12,
}}> }}>
展开查看 展开查看
</div> </div> */}
</>
)}
</div> </div>
</div> </div>
{/* 口播脚本台词 */} {/* 口播脚本台词 */}
<div style={{ marginBottom: 16 }}> {/* <div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: '#000000ff', marginBottom: 8 }}>口播脚本台词</div> <div style={{ fontSize: 12, color: '#000000ff', marginBottom: 8 }}>口播脚本台词</div>
<div style={{ fontSize: 13, color: '#000000ff', lineHeight: 1.6 }}> <div style={{ fontSize: 13, color: '#000000ff', lineHeight: 1.6 }}>
{previewAsset?.script || '还在为宝宝不爱喝水发愁?试试这款天然果蔬汁!零添加糖分,维生素满满,口感清甜宝宝超爱喝。现在下单还送专属吸管杯,手慢无!点下方链接把健康带回家~'} {previewAsset?.script || '还在为宝宝不爱喝水发愁?试试这款天然果蔬汁!零添加糖分,维生素满满,口感清甜宝宝超爱喝。现在下单还送专属吸管杯,手慢无!点下方链接把健康带回家~'}
</div> </div>
{/* <div style={{ <div style={{
fontSize: 12, fontSize: 12,
color: '#6366f1', color: '#6366f1',
marginTop: 8, marginTop: 8,
@@ -1019,8 +1135,8 @@ const HomePage: React.FC = () => {
gap: 4, gap: 4,
}}> }}>
完整内容女声 完整内容女声
</div> */}
</div> </div>
</div> */}
{/* 视频标签 */} {/* 视频标签 */}
{/* <div style={{ marginBottom: 16 }}> {/* <div style={{ marginBottom: 16 }}>
@@ -1118,9 +1234,16 @@ const HomePage: React.FC = () => {
</div> */} </div> */}
<div style={{ display: 'flex', gap: 12 }}> <div style={{ display: 'flex', gap: 12 }}>
{previewAsset?.mediaType !== 'video' && ( {/* {previewAsset?.mediaType !== 'video' && ( */}
<Button <Button
// onClick={() => navigate('/generate')} onClick={() => {
navigate('/conversation', {
state: {
generationPrompt: previewAsset?.generationPrompt,
mediaReferences: previewAsset?.mediaReferences,
},
});
}}
style={{ style={{
padding: '8px 24px', padding: '8px 24px',
borderRadius: 8, borderRadius: 8,
@@ -1133,7 +1256,7 @@ const HomePage: React.FC = () => {
> >
AI创作 AI创作
</Button> </Button>
)} {/* )}
<Button <Button
// onClick={() => navigate('/initial')} // onClick={() => navigate('/initial')}
style={{ style={{
@@ -1147,7 +1270,7 @@ const HomePage: React.FC = () => {
}} }}
> >
去爆款复刻 去爆款复刻
</Button> </Button> */}
</div> </div>
</div> </div>
</Modal> </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 { Button, Drawer, Input, Modal, Popconfirm, Spin, Table, Tag, Tooltip, Upload, message } from 'antd';
import { ArrowLeftOutlined, PlusOutlined, XOutlined } from '@ant-design/icons'; import { ArrowLeftOutlined, PlusOutlined, XOutlined } from '@ant-design/icons';
import {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'; import VideoTrimPicker from '../components/VideoTrimPicker';
@@ -169,6 +169,16 @@ function RemoveInfo() {
} }
}, [fetchSegments]); }, [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) => { const handleDeleteSegment = useCallback(async (segmentId: string) => {
try { try {
await deleteSegment(segmentId); await deleteSegment(segmentId);
@@ -589,11 +599,11 @@ function RemoveInfo() {
}, },
{ {
title: '操作', title: '操作',
width: 140, width: 200,
fixed: 'right', fixed: 'right',
align: 'center' as const, align: 'center' as const,
render: (_: any, record: any) => ( render: (_: any, record: any) => (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{record.moduleProjectId ? ( {record.moduleProjectId ? (
<Button <Button
type="text" type="text"
@@ -616,11 +626,20 @@ function RemoveInfo() {
<Button <Button
type="text" type="text"
onClick={() => handleSegmentReanalyze(String(record.id))} 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> </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 <Popconfirm
title="确定删除此片段?" title="确定删除此片段?"
onConfirm={() => handleDeleteSegment(String(record.id))} onConfirm={() => handleDeleteSegment(String(record.id))}