516 lines
22 KiB
TypeScript
516 lines
22 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
import { Button, Drawer, Input, Table, Tag, Upload, message } from 'antd';
|
|
import { ArrowLeftOutlined, PlusOutlined, XOutlined } from '@ant-design/icons';
|
|
import { createRemoveLens, getShotReplicationDetail, Removelist, removeCreate, splitCustom, uploadImage } from '../api';
|
|
import VideoTrimPicker from '../components/VideoTrimPicker';
|
|
|
|
const { TextArea } = Input;
|
|
|
|
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
|
const MIN_TRIM_SECONDS = 2;
|
|
const MAX_TRIM_SECONDS = 15;
|
|
|
|
function buildAssetUrl(url?: string): string {
|
|
if (!url) return '';
|
|
if (/^https?:\/\//i.test(url)) return url;
|
|
return `${API_BASE}${url}`;
|
|
}
|
|
|
|
function RemoveInfo() {
|
|
const { creatID } = useParams<{ creatID: string }>();
|
|
const navigate = useNavigate();
|
|
const [drawerVisible, setDrawerVisible] = useState(false);
|
|
const [trimModalVisible, setTrimModalVisible] = useState(false);
|
|
const [currentSegment, setCurrentSegment] = useState<string | null>(null);
|
|
const [productName, setProductName] = useState('');
|
|
const [productSellingPoint, setProductSellingPoint] = useState('');
|
|
const [productImage, setProductImage] = useState('');
|
|
const [taskDetail, setTaskDetail] = useState<any>(null);
|
|
const [tableData, setTableData] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [splitLoading, setSplitLoading] = useState(false);
|
|
const [autoSplitLoading, setAutoSplitLoading] = useState(false);
|
|
|
|
const videoUrl = useMemo(() => buildAssetUrl(taskDetail?.videoUrl), [taskDetail?.videoUrl]);
|
|
|
|
const fetchTaskDetail = useCallback(async () => {
|
|
if (!creatID) return;
|
|
try {
|
|
const res = await getShotReplicationDetail(creatID);
|
|
setTaskDetail(res);
|
|
} catch {
|
|
message.error('获取任务详情失败');
|
|
}
|
|
}, [creatID]);
|
|
|
|
const fetchSegments = useCallback(async () => {
|
|
if (!creatID) return;
|
|
try {
|
|
const res = await Removelist(creatID);
|
|
setTableData(res.items || []);
|
|
} catch {
|
|
message.error('获取拆镜列表失败');
|
|
}
|
|
}, [creatID]);
|
|
|
|
const refreshPageData = useCallback(async () => {
|
|
await Promise.all([fetchTaskDetail(), fetchSegments()]);
|
|
}, [fetchTaskDetail, fetchSegments]);
|
|
|
|
useEffect(() => {
|
|
refreshPageData();
|
|
}, [refreshPageData]);
|
|
|
|
const handleGenerate = (segmentId: string) => {
|
|
setCurrentSegment(segmentId);
|
|
setDrawerVisible(true);
|
|
};
|
|
|
|
const handleCloseDrawer = () => {
|
|
setDrawerVisible(false);
|
|
setCurrentSegment(null);
|
|
setProductName('');
|
|
setProductSellingPoint('');
|
|
setProductImage('');
|
|
};
|
|
|
|
const handleProductImageChange: any = (info: any) => {
|
|
if (info.fileList.length === 0) {
|
|
setProductImage('');
|
|
}
|
|
};
|
|
|
|
const beforeUploadProductImage = async (file: File) => {
|
|
try {
|
|
const uploadResult = await uploadImage(file);
|
|
setProductImage(uploadResult.url);
|
|
message.success('图片上传成功');
|
|
} catch {
|
|
message.error('图片上传失败,请重试');
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const handleManualGenerate = async () => {
|
|
if (!currentSegment) {
|
|
message.warning('请先选择拆镜片段');
|
|
return;
|
|
}
|
|
if (!productImage) {
|
|
message.warning('请上传产品图');
|
|
return;
|
|
}
|
|
if (!productName.trim()) {
|
|
message.warning('请输入产品名称');
|
|
return;
|
|
}
|
|
if (!productSellingPoint.trim()) {
|
|
message.warning('请输入产品卖点');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const params = {
|
|
target_project_name: productName.trim(),
|
|
core_content_point: productSellingPoint.trim(),
|
|
material_image_url: buildAssetUrl(productImage),
|
|
idempotency_key: `replication_${Date.now()}`,
|
|
};
|
|
|
|
await removeCreate(currentSegment, params);
|
|
message.success('视频生成任务创建成功');
|
|
handleCloseDrawer();
|
|
await fetchSegments();
|
|
} catch (err: any) {
|
|
message.error(err?.message || '创建失败,请重试');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleAutoGenerate = async () => {
|
|
if (!creatID) return;
|
|
setAutoSplitLoading(true);
|
|
try {
|
|
await createRemoveLens(creatID, {
|
|
selected_indices: [],
|
|
replace_existing: false,
|
|
});
|
|
message.success('AI 拆镜任务已提交');
|
|
await refreshPageData();
|
|
} catch (error: any) {
|
|
message.error(error?.message || '拆镜失败');
|
|
} finally {
|
|
setAutoSplitLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleOpenTrimModal = () => {
|
|
if (!videoUrl) {
|
|
message.warning('原视频地址不存在');
|
|
return;
|
|
}
|
|
setTrimModalVisible(true);
|
|
};
|
|
|
|
const handleCustomSplit = async (range: { startSecond: number; endSecond: number; durationSecond: number }) => {
|
|
if (!creatID) return;
|
|
if (range.durationSecond < MIN_TRIM_SECONDS) {
|
|
message.warning(`拆镜片段不能低于 ${MIN_TRIM_SECONDS} 秒`);
|
|
return;
|
|
}
|
|
if (range.durationSecond > MAX_TRIM_SECONDS) {
|
|
message.warning(`拆镜片段不能超过 ${MAX_TRIM_SECONDS} 秒`);
|
|
return;
|
|
}
|
|
|
|
setSplitLoading(true);
|
|
try {
|
|
await splitCustom(creatID, {
|
|
start_second: range.startSecond,
|
|
end_second: range.endSecond,
|
|
});
|
|
message.success('手动拆镜任务已提交');
|
|
setTrimModalVisible(false);
|
|
await refreshPageData();
|
|
} catch (error: any) {
|
|
message.error(error?.message || '手动拆镜失败');
|
|
} finally {
|
|
setSplitLoading(false);
|
|
}
|
|
};
|
|
|
|
const canCreateReplication = (record: any) => {
|
|
return record?.splitStatus === 'completed' && !!record?.segmentVideoUrl;
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
title: '片段',
|
|
width: 100,
|
|
align: 'center' as const,
|
|
render: (_: any, record: any) => (
|
|
<div>
|
|
<div style={{ fontSize: 16, fontWeight: 600, color: '#333' }}>{record.segmentName || `片段${record.segmentIndex || ''}`}</div>
|
|
<div style={{ fontSize: 12, color: '#999' }}>{record.timeNode}</div>
|
|
{record.sourceMode === 'custom' && <Tag color="blue" style={{ marginTop: 6 }}>手动</Tag>}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: '片段视频',
|
|
width: 150,
|
|
align: 'center' as const,
|
|
render: (_: any, record: any) => (
|
|
<div style={{ position: 'relative', width: 120, height: 80, borderRadius: 6, overflow: 'hidden', background: '#f1f5f9' }}>
|
|
{record.segmentVideoUrl ? (
|
|
<video
|
|
controls
|
|
src={buildAssetUrl(record.segmentVideoUrl)}
|
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
/>
|
|
) : (
|
|
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 12 }}>
|
|
{record.splitStatus === 'failed' ? '切割失败' : '切割中'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: '视频内容',
|
|
width: 250,
|
|
align: 'left' as const,
|
|
render: (_: any, record: any) => (
|
|
<div style={{ fontSize: 14, color: '#333', lineHeight: 1.6 }}>
|
|
{record.segmentContent || record.lastError || '-'}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: '视频类型',
|
|
width: 120,
|
|
align: 'center' as const,
|
|
render: (_: any, record: any) => (
|
|
<div style={{ fontSize: 14, color: '#333', fontWeight: 500 }}>
|
|
{record.segmentCategory || '-'}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: '状态',
|
|
width: 120,
|
|
align: 'center' as const,
|
|
render: (_: any, record: any) => {
|
|
const statusMap: Record<string, { text: string; color: string }> = {
|
|
pending: { text: '待切割', color: 'default' },
|
|
processing: { text: '切割中', color: 'processing' },
|
|
retry_waiting: { text: '等待重试', color: 'warning' },
|
|
completed: { text: '已完成', color: 'success' },
|
|
failed: { text: '失败', color: 'error' },
|
|
};
|
|
const item = statusMap[record.splitStatus] || { text: record.splitStatus || '-', color: 'default' };
|
|
return <Tag color={item.color}>{item.text}</Tag>;
|
|
},
|
|
},
|
|
{
|
|
title: '素材',
|
|
width: 140,
|
|
align: 'center' as const,
|
|
render: (_: any, record: any) => (
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
|
|
{record.moduleProjectId ? (
|
|
<Button
|
|
type="text"
|
|
onClick={() => navigate(`/removelens/${record.moduleProjectId}/removefenbu`)}
|
|
style={{ color: '#656efa', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
|
|
>
|
|
详情任务
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
type="text"
|
|
onClick={() => handleGenerate(String(record.id))}
|
|
disabled={!canCreateReplication(record)}
|
|
style={{ color: canCreateReplication(record) ? '#656efa' : '#94a3b8', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
|
|
>
|
|
{canCreateReplication(record) ? '视频生成' : '待切割完成'}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
style={{
|
|
minHeight: 'calc(100vh - 90px)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<div style={{ background: '#fff' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
<Button
|
|
type="text"
|
|
icon={<ArrowLeftOutlined />}
|
|
onClick={() => navigate(-1)}
|
|
style={{ fontSize: 16, color: '#666' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{taskDetail ? (
|
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', overflow: 'hidden' }}>
|
|
<div style={{ flex: 0.4, background: '#fff', borderRadius: 12 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 30, marginBottom: 12 }}>
|
|
<div style={{ position: 'relative', width: 280, height: 160, borderRadius: 8, overflow: 'hidden', flexShrink: 0 }}>
|
|
<video
|
|
controls
|
|
src={videoUrl}
|
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
|
<div>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<h2 style={{ fontSize: 20, fontWeight: 600, marginBottom: 16 }}>视频总结</h2>
|
|
<div style={{ textAlign: 'right' }}>
|
|
<span style={{ color: '#999', fontSize: 12 }}>上传时间: {taskDetail.createdAt}</span>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', marginBottom: 12 }}>
|
|
<span style={{ color: '#999', fontSize: 14, marginRight: 12, width: 80, flexShrink: 0 }}>产品名称:</span>
|
|
<span style={{ color: '#333', fontWeight: 500 }}>{taskDetail.title}</span>
|
|
</div>
|
|
<div style={{ display: 'flex', marginBottom: 12 }}>
|
|
<span style={{ color: '#999', fontSize: 14, marginRight: 12, width: 80, flexShrink: 0 }}>受众群体:</span>
|
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
{(taskDetail.originalVideoAudience?.split('、') || []).map((point: string, index: number) => (
|
|
<Tag key={index} color="purple" style={{ fontSize: 12 }}>
|
|
{point}
|
|
</Tag>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex' }}>
|
|
<span style={{ color: '#999', fontSize: 14, marginRight: 12, width: 80, flexShrink: 0 }}>视频内容:</span>
|
|
<span style={{ color: '#333' }}>{taskDetail.originalVideoContent}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: 16, marginBottom: 20, marginTop: 20 }}>
|
|
<Button
|
|
type="default"
|
|
onClick={handleOpenTrimModal}
|
|
disabled={!videoUrl || splitLoading}
|
|
style={{
|
|
flex: 1,
|
|
height: 48,
|
|
borderRadius: 8,
|
|
borderColor: '#6366f1',
|
|
color: '#6366f1',
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
手动
|
|
</Button>
|
|
<Button
|
|
type="primary"
|
|
onClick={handleAutoGenerate}
|
|
loading={autoSplitLoading}
|
|
disabled={autoSplitLoading}
|
|
style={{
|
|
flex: 1,
|
|
height: 48,
|
|
borderRadius: 8,
|
|
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
|
fontWeight: 500,
|
|
border: 'none',
|
|
}}
|
|
>
|
|
自动
|
|
</Button>
|
|
</div>
|
|
|
|
<div style={{ flex: 0.6, background: '#fff', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
|
<Table
|
|
columns={columns}
|
|
dataSource={tableData}
|
|
pagination={false}
|
|
bordered={false}
|
|
rowKey="id"
|
|
scroll={{ y: '1005' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<span style={{ fontSize: 16, color: '#999' }}>加载中...</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<VideoTrimPicker
|
|
open={trimModalVisible}
|
|
videoUrl={videoUrl}
|
|
title="手动视频切片"
|
|
loading={splitLoading}
|
|
minDuration={MIN_TRIM_SECONDS}
|
|
maxDuration={MAX_TRIM_SECONDS}
|
|
onCancel={() => setTrimModalVisible(false)}
|
|
onConfirm={handleCustomSplit}
|
|
/>
|
|
|
|
<Drawer
|
|
title={
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
|
<span>
|
|
<span style={{ color: '#6366f1' }}>智能视频复刻</span>
|
|
<span style={{ color: '#6366f1' }}>-片段{currentSegment}</span>
|
|
</span>
|
|
<Button
|
|
type="text"
|
|
icon={<XOutlined />}
|
|
onClick={handleCloseDrawer}
|
|
style={{ padding: 0 }}
|
|
/>
|
|
</div>
|
|
}
|
|
placement="right"
|
|
closable={false}
|
|
onClose={handleCloseDrawer}
|
|
open={drawerVisible}
|
|
size={480}
|
|
styles={{
|
|
body: { padding: '24px' },
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
|
<div>
|
|
<label style={{ fontWeight: 400, color: '#333', marginBottom: 12, display: 'block' }}>
|
|
产品白底图 <span style={{ color: '#ff4d4f' }}>*</span>
|
|
</label>
|
|
<div style={{ display: 'flex', gap: 16 }}>
|
|
<Upload
|
|
listType="picture-card"
|
|
onChange={handleProductImageChange}
|
|
beforeUpload={beforeUploadProductImage}
|
|
maxCount={1}
|
|
accept="image/*"
|
|
style={{ width: 140, height: 140 }}
|
|
>
|
|
{!productImage && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
|
|
<PlusOutlined style={{ fontSize: 24, color: '#999' }} />
|
|
<span style={{ fontSize: 12, color: '#999' }}>产品图 *</span>
|
|
</div>
|
|
)}
|
|
</Upload>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label style={{ fontWeight: 500, color: '#333', marginBottom: 12, display: 'block' }}>
|
|
产品名称
|
|
</label>
|
|
<Input
|
|
value={productName}
|
|
onChange={(e) => setProductName(e.target.value)}
|
|
placeholder="请输入产品名称"
|
|
style={{ height: 48, borderRadius: 8 }}
|
|
maxLength={10}
|
|
showCount
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label style={{ fontWeight: 500, color: '#333', marginBottom: 12, display: 'block' }}>
|
|
产品卖点
|
|
</label>
|
|
<TextArea
|
|
value={productSellingPoint}
|
|
onChange={(e) => setProductSellingPoint(e.target.value)}
|
|
placeholder="请输入产品卖点"
|
|
style={{ borderRadius: 8 }}
|
|
maxLength={100}
|
|
showCount
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: 16, marginTop: 24 }}>
|
|
<Button
|
|
type="default"
|
|
onClick={handleManualGenerate}
|
|
loading={loading}
|
|
disabled={loading}
|
|
style={{
|
|
flex: 1,
|
|
height: 48,
|
|
borderRadius: 8,
|
|
borderColor: '#6366f1',
|
|
color: '#6366f1',
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{loading ? '生成中...' : '手动生成'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Drawer>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default RemoveInfo;
|