真人人像修改

This commit is contained in:
sjy
2026-07-08 17:15:19 +08:00
parent 35ffa317e7
commit bb53063358
17 changed files with 1320 additions and 679 deletions
+21 -1
View File
@@ -570,6 +570,26 @@ export async function removethree(projectId: string, stepId: string ,params: any
export async function removefour(projectId: string, stepId: string ,params: any): Promise<any> { export async function removefour(projectId: string, stepId: string ,params: any): Promise<any> {
return api.post(`/shot-replications/projects/${projectId}/steps/${stepId}/generate-video`, params); return api.post(`/shot-replications/projects/${projectId}/steps/${stepId}/generate-video`, params);
} }
// 重新分析
export async function reanalyzeShotReplication(taskSetId: string): Promise<any> {
return api.post(`/shot-replications/task-sets/${taskSetId}/reanalyze`);
}
// 删除片段
export async function deleteSegment(segmentId: string): Promise<void> {
await api.delete(`/shot-replications/segments/${segmentId}`);
}
// 重新分析片段
export async function reanalyzeSegment(segmentId: string): Promise<any> {
return api.post(`/shot-replications/segments/${segmentId}/reanalyze`);
}
// 删除拆镜项目
export async function deleteShotReplicationProject(projectId: string): Promise<void> {
await api.delete(`/shot-replications/projects/${projectId}`);
}
// 删除爆款开头复刻任务
export async function deleteHotOpeningReplicationTask(taskId: string): Promise<void> {
await api.delete(`/hot-opening-replications/tasks/${taskId}`);
}
// 获取地区信息 // 获取地区信息
export interface GetAreaParams { export interface GetAreaParams {
level?: string; level?: string;
@@ -734,7 +754,7 @@ export async function getHomeCaseHeader(): Promise<any> {
return api.get(`/home-materials/categories`); return api.get(`/home-materials/categories`);
} }
// 首页素材按钮资源 // 首页素材按钮资源
export async function getHomeCaseButton(id: string,limit:number=5): Promise<any> { export async function getHomeCaseButton(id: string,limit:number=10): Promise<any> {
return api.get(`/home-materials?category_id=${id}&limit_per_category=${limit}&include_empty_categories=false&response_mode=grouped&page=1&page_size=20`); return api.get(`/home-materials?category_id=${id}&limit_per_category=${limit}&include_empty_categories=false&response_mode=grouped&page=1&page_size=20`);
} }
export async function deleteResourcesMaterial(params:any): Promise<any> { export async function deleteResourcesMaterial(params:any): Promise<any> {
@@ -179,16 +179,16 @@ const StorageCard: React.FC<{ data: ResourceCapacityData | null }> = ({ data })
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: 6, marginBottom: 6,
fontSize: 12, fontSize: 12,
color: isOver ? '#ffffffff' : '#000000ff', color: isOver ? '#000000ff' : '#000000ff',
padding: '0 8px', padding: '0 8px',
}}> }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}> <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<DatabaseOutlined style={{ fontSize: 12, color: isOver ? '#ffffffff' :'#000000ff' }} /> <DatabaseOutlined style={{ fontSize: 12, color: isOver ? '#000000ff' :'#000000ff' }} />
{rawPercent.toFixed(1)}% {rawPercent.toFixed(1)}%
</span> </span>
{data.enabled ? ( {data.enabled ? (
<span style={{ fontWeight: 500, color: isOver ? '#ffffffff' : '#000000ff' }}> <span style={{ fontWeight: 500, color: isOver ? '#000000ff' : '#000000ff' }}>
{used.toFixed(2)} / {total.toFixed(2)} {unit} {used.toFixed(2)} / {total.toFixed(2)} {unit}
</span> </span>
) : ( ) : (
+149 -122
View File
@@ -1,60 +1,73 @@
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { Modal, Tooltip } from 'antd'; import { Popover, Tooltip, Modal } from 'antd';
import { HistoryOutlined, UserOutlined, FolderOpenOutlined, PlusOutlined, TeamOutlined } from '@ant-design/icons'; import { FolderOpenOutlined, DatabaseOutlined, UserOutlined, TeamOutlined } from '@ant-design/icons';
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset } from '../types'; import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset } from '../types';
import PrivatePortraitAssetPicker from './privatePortrait/picker/AssetPicker'; import PrivatePortraitAssetPicker from './privatePortrait/picker/AssetPicker';
interface UploadSelectorProps { interface UploadSelectorProps {
children: React.ReactNode; children: React.ReactNode;
accept?: string; accept?: string;
multiple?: boolean;
onLocalSelect?: (files: File[]) => void; onLocalSelect?: (files: File[]) => void;
onHistorySelect?: (items: any[]) => void; onHistorySelect?: (items: any[]) => void;
/** 兼容旧页面:由 UploadSelector 内部打开素材选择器,确认后回传素材数组。 */
onPortraitSelect?: (items: PrivatePortraitSelectableAsset[]) => void; onPortraitSelect?: (items: PrivatePortraitSelectableAsset[]) => void;
/** 新页面推荐:只选择素材库类型,父组件自行打开统一选择器。 */
onPortraitLibrarySelect?: (libraryType: PrivatePortraitLibraryType) => void; onPortraitLibrarySelect?: (libraryType: PrivatePortraitLibraryType) => void;
uploading?: boolean; uploading?: boolean;
tooltipTitle?: string; tooltipTitle?: string;
maxImageCount?: number;
maxVideoCount?: number;
usedImageCount?: number;
usedVideoCount?: number;
usedVideoDuration?: number;
maxVideoDuration?: number;
} }
const UploadSelector: React.FC<UploadSelectorProps> = ({ const UploadSelector: React.FC<UploadSelectorProps> = ({
children, children,
accept = 'image/*,video/*', accept = 'image/*,video/*',
multiple = true,
onLocalSelect, onLocalSelect,
onHistorySelect, onHistorySelect,
onPortraitSelect, onPortraitSelect,
onPortraitLibrarySelect, onPortraitLibrarySelect,
uploading, uploading,
tooltipTitle, tooltipTitle,
maxImageCount,
maxVideoCount,
usedImageCount,
usedVideoCount,
usedVideoDuration,
maxVideoDuration,
}) => { }) => {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [modalVisible, setModalVisible] = useState(false);
const [historyModalVisible, setHistoryModalVisible] = useState(false);
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 [popoverOpen, setPopoverOpen] = useState(false);
const handleLocalSelect = () => { const handleLocalSelect = () => {
setPopoverOpen(false);
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files; const files = e.target.files;
if (files && onLocalSelect) { if (files && onLocalSelect) {
onLocalSelect(Array.from(files)); const fileList = Array.from(files);
onLocalSelect(fileList);
} }
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
}; };
const handleClick = () => {
if (!uploading) {
setModalVisible(true);
}
};
const openPortraitPicker = (libraryType: PrivatePortraitLibraryType) => { const openPortraitPicker = (libraryType: PrivatePortraitLibraryType) => {
setModalVisible(false); setPopoverOpen(false);
if (onPortraitSelect) {
setPortraitLibraryType(libraryType);
setPortraitPickerOpen(true);
return;
}
if (onPortraitLibrarySelect) { if (onPortraitLibrarySelect) {
onPortraitLibrarySelect(libraryType); onPortraitLibrarySelect(libraryType);
return; return;
@@ -63,48 +76,102 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
setPortraitPickerOpen(true); setPortraitPickerOpen(true);
}; };
const handleHistorySelect = () => {
setPopoverOpen(false);
setHistoryModalVisible(true);
};
const confirmHistorySelection = () => { const confirmHistorySelection = () => {
onHistorySelect?.([]); onHistorySelect?.([]);
setHistoryModalVisible(false); setHistoryModalVisible(false);
setModalVisible(false);
}; };
const options = [ const content = (
{ <div style={{ padding: '4px 0', minWidth: 180 }}>
key: 'history', <div
label: '历史记录', onClick={() => {
icon: <HistoryOutlined style={{ fontSize: 20, color: '#6366f1' }} />, handleLocalSelect();
description: '从历史上传记录中选择', }}
onClick: () => { style={{
setModalVisible(false); display: 'flex',
setHistoryModalVisible(true); alignItems: 'center',
}, gap: 12,
}, padding: '8px 16px',
{ cursor: 'pointer',
key: 'real_person', transition: 'background 0.15s ease',
label: '真人素材', }}
icon: <UserOutlined style={{ fontSize: 20, color: '#ec4899' }} />, onMouseEnter={(e) => {
description: '从真人私域素材库中选择', e.currentTarget.style.background = '#f1f5f9';
onClick: () => openPortraitPicker('real_person'), }}
}, onMouseLeave={(e) => {
{ e.currentTarget.style.background = 'transparent';
key: 'aigc_virtual', }}
label: '虚拟素材', >
icon: <TeamOutlined style={{ fontSize: 20, color: '#8b5cf6' }} />, <FolderOpenOutlined style={{ fontSize: 14, color: '#64748b' }} />
description: '从虚拟私域素材库中选择', <span style={{ fontSize: 14, color: '#334155' }}>{multiple ? '本地上传(可多选拖拽)' : '本地上传'}</span>
onClick: () => openPortraitPicker('aigc_virtual'), </div>
}, <div
{ onClick={handleHistorySelect}
key: 'local', style={{
label: '本地选取', display: 'flex',
icon: <FolderOpenOutlined style={{ fontSize: 20, color: '#10b981' }} />, alignItems: 'center',
description: '从本地电脑选择文件', gap: 12,
onClick: () => { padding: '8px 16px',
setModalVisible(false); cursor: 'pointer',
handleLocalSelect(); transition: 'background 0.15s ease',
}, }}
}, onMouseEnter={(e) => {
]; e.currentTarget.style.background = '#f1f5f9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<DatabaseOutlined style={{ fontSize: 14, color: '#64748b' }} />
<span style={{ fontSize: 14, color: '#334155' }}></span>
</div>
<div
onClick={() => openPortraitPicker('real_person')}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 16px',
cursor: 'pointer',
transition: 'background 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f1f5f9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<UserOutlined style={{ fontSize: 14, color: '#64748b' }} />
<span style={{ fontSize: 14, color: '#334155' }}></span>
</div>
<div
onClick={() => openPortraitPicker('aigc_virtual')}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 16px',
cursor: 'pointer',
transition: 'background 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f1f5f9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<TeamOutlined style={{ fontSize: 14, color: '#64748b' }} />
<span style={{ fontSize: 14, color: '#334155' }}></span>
</div>
</div>
);
return ( return (
<> <>
@@ -112,86 +179,38 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept={accept} accept={accept}
multiple multiple={multiple}
onChange={handleFileChange} onChange={handleFileChange}
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
{tooltipTitle ? ( {tooltipTitle ? (
<Tooltip title={tooltipTitle}> <Tooltip title={tooltipTitle}>
<div onClick={handleClick} style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}> <Popover
{children} content={content}
</div> open={popoverOpen}
onOpenChange={setPopoverOpen}
trigger="click"
placement="bottomLeft"
>
<div style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
{children}
</div>
</Popover>
</Tooltip> </Tooltip>
) : ( ) : (
<div onClick={handleClick} style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}> <Popover
{children} content={content}
</div> open={popoverOpen}
onOpenChange={setPopoverOpen}
trigger="click"
placement="bottomLeft"
>
<div style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
{children}
</div>
</Popover>
)} )}
<Modal
title="选择上传来源"
open={modalVisible}
onCancel={() => setModalVisible(false)}
footer={null}
width={400}
centered
destroyOnHidden
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, paddingTop: 8 }}>
{options.map((option) => (
<div
key={option.key}
onClick={option.onClick}
style={{
display: 'flex',
alignItems: 'center',
gap: 16,
padding: '16px 20px',
borderRadius: 12,
background: '#f8fafc',
cursor: 'pointer',
transition: 'all 0.2s ease',
border: '1px solid transparent',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#fff';
e.currentTarget.style.borderColor = '#e2e8f0';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.04)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#f8fafc';
e.currentTarget.style.borderColor = 'transparent';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div
style={{
width: 48,
height: 48,
borderRadius: 12,
background: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
}}
>
{option.icon}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 15, fontWeight: 600, color: '#1e293b', marginBottom: 2 }}>
{option.label}
</div>
<div style={{ fontSize: 13, color: '#64748b' }}>
{option.description}
</div>
</div>
<PlusOutlined style={{ fontSize: 14, color: '#94a3b8' }} />
</div>
))}
</div>
</Modal>
<Modal <Modal
title="选择资产素材" title="选择资产素材"
open={historyModalVisible} open={historyModalVisible}
@@ -234,6 +253,14 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
onPortraitSelect?.(assets); onPortraitSelect?.(assets);
setPortraitPickerOpen(false); setPortraitPickerOpen(false);
}} }}
accept={accept}
maxCount={accept === 'image/*' && !multiple ? 1 : undefined}
maxImageCount={maxImageCount}
maxVideoCount={maxVideoCount}
usedImageCount={usedImageCount}
usedVideoCount={usedVideoCount}
usedVideoDuration={usedVideoDuration}
maxVideoDuration={maxVideoDuration}
/> />
</> </>
); );
@@ -1,6 +1,6 @@
import React from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Button, Empty, Popconfirm, Space, Tag, Tooltip } from 'antd'; import { Button, Card, Empty, Modal, Popconfirm, Space, Tag, Tooltip } from 'antd';
import { DeleteOutlined, PictureOutlined, ReloadOutlined, VideoCameraOutlined } from '@ant-design/icons'; import { DeleteOutlined, EyeOutlined, PictureOutlined, VideoCameraOutlined } from '@ant-design/icons';
import type { PrivatePortraitAsset } from '../../../types'; import type { PrivatePortraitAsset } from '../../../types';
const statusColor: Record<string, string> = { const statusColor: Record<string, string> = {
@@ -12,11 +12,20 @@ const statusColor: Record<string, string> = {
delete_failed: 'red', delete_failed: 'red',
}; };
const statusText: Record<string, string> = {
Active: '入库成功',
Processing: '入库处理中',
Failed: '入库失败',
local_deleted: '本地已删除',
remote_deleted: '远程已删除',
delete_failed: '删除失败',
};
interface Props { interface Props {
items: PrivatePortraitAsset[]; items: PrivatePortraitAsset[];
loading?: boolean; loading?: boolean;
onSync: (assetId: string) => void;
onDelete: (assetId: string) => void; onDelete: (assetId: string) => void;
onRefresh?: () => void;
} }
const buildPreviewUrl = (url?: string | null) => { const buildPreviewUrl = (url?: string | null) => {
@@ -39,53 +48,114 @@ const formatDuration = (value?: number | null) => {
return `${duration.toFixed(duration >= 10 ? 0 : 1)}s`; return `${duration.toFixed(duration >= 10 ? 0 : 1)}s`;
}; };
const PrivatePortraitAssetGrid: React.FC<Props> = ({ items, onSync, onDelete }) => { const PrivatePortraitAssetGrid: React.FC<Props> = ({ items, onDelete, onRefresh }) => {
if (!items.length) return <Empty description="暂无真人素材" />; const pollingRef = useRef<number | null>(null);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewUrl, setPreviewUrl] = useState('');
const [previewType, setPreviewType] = useState<'Image' | 'Video'>('Image');
useEffect(() => {
const needsPolling = items.some(
(item) => item.status !== 'Failed' && item.status !== 'Active'
);
if (needsPolling && onRefresh) {
if (!pollingRef.current) {
pollingRef.current = window.setInterval(() => {
onRefresh();
}, 3000);
}
} else {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
}
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
};
}, [items, onRefresh]);
const openPreview = (asset: PrivatePortraitAsset) => {
const url = getAssetPreviewUrl(asset);
if (!url) return;
setPreviewUrl(url);
setPreviewType(asset.assetType === 'Video' ? 'Video' : 'Image');
setPreviewOpen(true);
};
if (!items.length) return <Empty description="暂无素材" />;
return ( return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 14 }}> <>
{items.map((item) => { <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 14 }}>
const isVideo = item.assetType === 'Video'; {items.map((item) => {
const previewUrl = getAssetPreviewUrl(item); const isVideo = item.assetType === 'Video';
return ( const previewUrl = getAssetPreviewUrl(item);
<div key={item.id} style={{ border: '1px solid #e2e8f0', borderRadius: 12, overflow: 'hidden', background: '#fff' }}> return (
<div style={{ height: 150, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}> <Card
{previewUrl ? ( key={item.id}
isVideo ? ( hoverable
<video src={previewUrl} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> bodyStyle={{ padding: 12 }}
) : ( style={{ borderRadius: 16, overflow: 'hidden', borderColor: '#eef2f7' }}
<img src={previewUrl} alt={item.name || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> cover={(
) <div style={{ height: 170, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
) : isVideo ? ( {previewUrl ? (
<VideoCameraOutlined style={{ fontSize: 32, color: '#94a3b8' }} /> isVideo && item.videoCoverUrl ? (
) : ( <img src={previewUrl} alt={item.name || '视频封面'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<PictureOutlined style={{ fontSize: 32, color: '#94a3b8' }} /> ) : isVideo ? (
)} <video src={previewUrl} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
{isVideo && ( ) : (
<div style={{ position: 'absolute', left: 8, bottom: 8, padding: '2px 6px', borderRadius: 8, background: 'rgba(15,23,42,0.72)', color: '#fff', fontSize: 12 }}> <img src={previewUrl} alt={item.name || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
{formatDuration(item.videoDuration)} )
) : isVideo ? (
<VideoCameraOutlined style={{ fontSize: 42, color: '#64748b' }} />
) : (
<PictureOutlined style={{ fontSize: 42, color: '#64748b' }} />
)}
{isVideo && <Tag color="blue" style={{ position: 'absolute', left: 10, top: 10 }}></Tag>}
{previewUrl && (
<Button size="small" shape="circle" icon={<EyeOutlined />} style={{ position: 'absolute', right: 10, top: 10 }} onClick={() => openPreview(item)} />
)}
{isVideo && (
<div style={{ position: 'absolute', left: 10, bottom: 10, padding: '2px 6px', borderRadius: 8, background: 'rgba(15,23,42,0.72)', color: '#fff', fontSize: 12 }}>
{formatDuration(item.videoDuration)}
</div>
)}
</div> </div>
)} )}
</div> >
<div style={{ padding: 10 }}> <Space direction="vertical" size={8} style={{ width: '100%' }}>
<Tooltip title={item.name || item.remoteAssetId}> <Tooltip title={item.name || item.remoteAssetId || item.id}>
<div style={{ fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.name || item.remoteAssetId}</div> <div style={{ fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.name || item.remoteAssetId || '未命名素材'}</div>
</Tooltip> </Tooltip>
<Space wrap size={4} style={{ marginTop: 8 }}> <Space wrap size={4}>
<Tag color={statusColor[item.status] || 'default'}>{item.status}</Tag> <Tag color={statusColor[item.status] || 'default'}>{statusText[item.status] || item.status}</Tag>
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag> <Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
</Space>
<Space size={6} wrap>
<Popconfirm title="确认删除这个素材吗?" onConfirm={() => onDelete(item.id)}>
<Button size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
</Space> </Space>
{item.errorMessage && <div style={{ color: '#ef4444', fontSize: 12, marginTop: 6 }}>{item.errorMessage}</div>} </Card>
<Space style={{ marginTop: 10 }} size={6}> );
<Button size="small" icon={<ReloadOutlined />} onClick={() => onSync(item.id)}></Button> })}
<Popconfirm title="确认删除这个素材吗?" onConfirm={() => onDelete(item.id)}> </div>
<Button size="small" danger icon={<DeleteOutlined />}></Button> <Modal title="素材预览" open={previewOpen} onCancel={() => setPreviewOpen(false)} footer={null} width={760} destroyOnClose>
</Popconfirm> <div style={{ minHeight: 420, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0f172a', borderRadius: 12, overflow: 'hidden' }}>
</Space> {previewType === 'Video' ? (
</div> <video src={previewUrl} controls autoPlay style={{ maxWidth: '100%', maxHeight: 520 }} />
</div> ) : (
); <img src={previewUrl} alt="素材预览" style={{ maxWidth: '100%', maxHeight: 520, objectFit: 'contain' }} />
})} )}
</div> </div>
</Modal>
</>
); );
}; };
@@ -1,10 +1,12 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState, useEffect } from 'react';
import { Tabs, Typography } from 'antd'; import { Card, Col, Row, Tabs, Typography, message } from 'antd';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { getPrivatePortraitConfig, getPrivatePortraitProjects, getPrivatePortraitVirtualConfig, getPrivatePortraitVirtualProjects } from '../../../api';
import type { PrivatePortraitConfig, PrivatePortraitProject } from '../../../types';
import RealPersonLibraryPanel from './RealPersonLibraryPanel'; import RealPersonLibraryPanel from './RealPersonLibraryPanel';
import VirtualMaterialPanel from './VirtualMaterialPanel'; import VirtualMaterialPanel from './VirtualMaterialPanel';
const { Title, Text } = Typography; const { Title, Text, Paragraph } = Typography;
type PrivatePortraitTabKey = 'real_person' | 'aigc_virtual'; type PrivatePortraitTabKey = 'real_person' | 'aigc_virtual';
@@ -15,6 +17,48 @@ const normalizeTabKey = (value?: string | null): PrivatePortraitTabKey => (
const PrivatePortraitLibraryPanel: React.FC = () => { const PrivatePortraitLibraryPanel: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [activeKey, setActiveKey] = useState<PrivatePortraitTabKey>(() => normalizeTabKey(searchParams.get('portraitTab'))); const [activeKey, setActiveKey] = useState<PrivatePortraitTabKey>(() => normalizeTabKey(searchParams.get('portraitTab')));
const [config, setConfig] = useState<PrivatePortraitConfig | null>(null);
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string>();
useEffect(() => {
const loadData = async () => {
try {
if (activeKey === 'aigc_virtual') {
const [configRes, projectsRes] = await Promise.all([
getPrivatePortraitVirtualConfig(),
getPrivatePortraitVirtualProjects({ page: 1, pageSize: 100, status: 'active' }),
]);
setConfig(configRes);
const projectList = projectsRes.items || [];
setProjects(projectList);
setSelectedProjectId((prev) => prev && projectList.some((item) => item.id === prev) ? prev : projectList[0]?.id);
} else {
const [configRes, projectsRes] = await Promise.all([
getPrivatePortraitConfig(),
getPrivatePortraitProjects({ pageSize: 100, status: 'active' }),
]);
setConfig(configRes);
const projectList = projectsRes.items || [];
setProjects(projectList);
setSelectedProjectId((prev) => prev && projectList.some((item) => item.id === prev) ? prev : projectList[0]?.id);
}
} catch (err: any) {
message.error(err?.message || '加载数据失败');
}
};
void loadData();
}, [activeKey]);
const selectedProject = useMemo(
() => projects.find((item) => item.id === selectedProjectId) || null,
[projects, selectedProjectId],
);
const quotaText = useMemo(() => {
if (!config) return '额度加载中';
return `已用 ${config.usedAssetCount || 0} / ${config.assetLimit || 0} 个素材,剩余 ${config.remainingAssetCount || 0}`;
}, [config]);
const items = useMemo(() => [ const items = useMemo(() => [
{ {
@@ -45,6 +89,33 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
<Title level={4} style={{ margin: 0 }}></Title> <Title level={4} style={{ margin: 0 }}></Title>
<Text type="secondary"> AIGC Asset Group</Text> <Text type="secondary"> AIGC Asset Group</Text>
</div> </div>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} md={8}>
<Card style={{ borderRadius: 16, background: 'linear-gradient(135deg,#f5f3ff,#fff)' }}>
<Text type="secondary"></Text>
<div style={{ fontSize: 24, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>//</Paragraph>
</Card>
</Col>
<Col xs={24} md={8}>
<Card style={{ borderRadius: 16 }}>
<Text type="secondary"></Text>
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{projects.length}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>
{activeKey === 'aigc_virtual'
? '虚拟人像项目会同步创建火山 AIGC Asset Group。'
: '真人项目组需完成人脸认证后才可上传素材。'}
</Paragraph>
</Card>
</Col>
<Col xs={24} md={8}>
<Card style={{ borderRadius: 16 }}>
<Text type="secondary"></Text>
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{selectedProject?.assetCount || 0}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}> Active AI </Paragraph>
</Card>
</Col>
</Row>
<Tabs <Tabs
activeKey={activeKey} activeKey={activeKey}
onChange={handleTabChange} onChange={handleTabChange}
@@ -1,8 +1,8 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Button, Card, Popconfirm, Space, Tag, Typography, message } from 'antd'; import { Button, Card, Empty, Input, Pagination, Popconfirm, Select, Space, Spin, Typography, message } from 'antd';
import { DeleteOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons'; import { DeleteOutlined, UploadOutlined } from '@ant-design/icons';
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types'; import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types';
import { deletePrivatePortraitAsset, deletePrivatePortraitProject, getPrivatePortraitAssets, syncPrivatePortraitAsset } from '../../../api'; import { deletePrivatePortraitAsset, deletePrivatePortraitProject, getPrivatePortraitAssets } from '../../../api';
import PrivatePortraitAssetGrid from './AssetGrid'; import PrivatePortraitAssetGrid from './AssetGrid';
import PrivatePortraitAssetUpload from './AssetUpload'; import PrivatePortraitAssetUpload from './AssetUpload';
@@ -16,12 +16,27 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]); const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false); const [uploadOpen, setUploadOpen] = useState(false);
const [keyword, setKeyword] = useState('');
const [assetStatus, setAssetStatus] = useState<string>();
const [assetType, setAssetType] = useState<string>();
const [assetPage, setAssetPage] = useState(1);
const [assetPageSize, setAssetPageSize] = useState(20);
const [assetTotal, setAssetTotal] = useState(0);
const loadAssets = async () => { const loadAssets = async (page = assetPage, pageSize = assetPageSize) => {
setLoading(true); setLoading(true);
try { try {
const res = await getPrivatePortraitAssets(project.id, { pageSize: 100 }); const res = await getPrivatePortraitAssets(project.id, {
page,
pageSize,
keyword,
status: assetStatus,
assetType: assetType as any,
});
setAssets(res.items); setAssets(res.items);
setAssetTotal(res.total || 0);
setAssetPage(page);
setAssetPageSize(pageSize);
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '加载素材失败'); message.error(e?.message || '加载素材失败');
} finally { } finally {
@@ -29,23 +44,12 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
} }
}; };
useEffect(() => { loadAssets(); }, [project.id]); useEffect(() => { loadAssets(1, assetPageSize); }, [project.id, keyword, assetStatus, assetType]);
const handleSync = async (assetId: string) => {
try {
await syncPrivatePortraitAsset(assetId);
await loadAssets();
onChanged();
message.success('素材状态已刷新');
} catch (e: any) {
message.error(e?.message || '刷新失败');
}
};
const handleDeleteAsset = async (assetId: string) => { const handleDeleteAsset = async (assetId: string) => {
try { try {
await deletePrivatePortraitAsset(assetId); await deletePrivatePortraitAsset(assetId);
await loadAssets(); await loadAssets(assetPage, assetPageSize);
onChanged(); onChanged();
message.success('素材已删除'); message.success('素材已删除');
} catch (e: any) { } catch (e: any) {
@@ -67,26 +71,76 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
return ( return (
<Card <Card
title={<Space><span>{project.name}</span><Tag color={canUpload ? 'green' : 'processing'}>{project.status}</Tag></Space>} title={<Space><span>{project.name}</span></Space>}
extra={( extra={(
<Space> <Space>
<Button type="primary" icon={<UploadOutlined />} disabled={!canUpload} onClick={() => setUploadOpen(true)}></Button> <Button type="primary" icon={<UploadOutlined />} disabled={!canUpload} onClick={() => setUploadOpen(true)}></Button>
<Button icon={<ReloadOutlined />} onClick={loadAssets} loading={loading}></Button>
<Popconfirm title="确认删除这个真人素材项目组吗?" onConfirm={handleDeleteProject}> <Popconfirm title="确认删除这个真人素材项目组吗?" onConfirm={handleDeleteProject}>
<Button danger icon={<DeleteOutlined />}></Button> <Button danger icon={<DeleteOutlined />}></Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
)} )}
style={{ borderRadius: 12 }} style={{ borderRadius: 16 }}
> >
<Typography.Paragraph style={{ color: '#64748b' }}>{project.description || '暂无描述'}</Typography.Paragraph>
{!canUpload && ( {!canUpload && (
<Typography.Paragraph style={{ color: '#f97316' }}> <Typography.Paragraph style={{ color: '#f97316' }}>
</Typography.Paragraph> </Typography.Paragraph>
)} )}
<PrivatePortraitAssetGrid items={assets} loading={loading} onSync={handleSync} onDelete={handleDeleteAsset} /> <Space style={{ width: '100%', marginBottom: 16 }} wrap>
<PrivatePortraitAssetUpload projectId={project.id} open={uploadOpen} onClose={() => setUploadOpen(false)} onSuccess={() => { loadAssets(); onChanged(); }} /> <Input.Search
allowClear
placeholder="搜索素材名称"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onSearch={() => loadAssets(1, assetPageSize)}
style={{ width: 240 }}
/>
<Select
allowClear
placeholder="素材状态"
value={assetStatus}
onChange={(value) => setAssetStatus(value)}
style={{ width: 150 }}
options={[
{ value: 'Processing', label: '处理中' },
{ value: 'Active', label: '可用' },
{ value: 'Failed', label: '失败' },
]}
/>
<Select
allowClear
placeholder="素材类型"
value={assetType}
onChange={(value) => setAssetType(value)}
style={{ width: 130 }}
options={[
{ value: 'Image', label: '图片' },
{ value: 'Video', label: '视频' },
]}
/>
<Button onClick={() => loadAssets(1, assetPageSize)}></Button>
</Space>
<Spin spinning={loading}>
{assets.length === 0 ? (
<Empty description="暂无素材,上传图片/视频后会异步入库" style={{ marginTop: 80 }} />
) : (
<>
<PrivatePortraitAssetGrid items={assets} loading={loading} onDelete={handleDeleteAsset} onRefresh={() => loadAssets(assetPage, assetPageSize)} />
<div style={{ textAlign: 'right', marginTop: 16 }}>
<Pagination
current={assetPage}
pageSize={assetPageSize}
total={assetTotal}
showSizeChanger
showTotal={(value) => `${value} 个素材`}
onChange={(page, size) => loadAssets(page, size)}
/>
</div>
</>
)}
</Spin>
<PrivatePortraitAssetUpload projectId={project.id} open={uploadOpen} onClose={() => setUploadOpen(false)} onSuccess={() => { loadAssets(1, assetPageSize); onChanged(); }} />
</Card> </Card>
); );
}; };
@@ -1,7 +1,9 @@
import React from 'react'; import React from 'react';
import { Button, Empty, List, Tag } from 'antd'; import { Empty, Space, Tag, Typography } from 'antd';
import type { PrivatePortraitProject } from '../../../types'; import type { PrivatePortraitProject } from '../../../types';
const { Text } = Typography;
interface Props { interface Props {
items: PrivatePortraitProject[]; items: PrivatePortraitProject[];
selectedId?: string | null; selectedId?: string | null;
@@ -11,24 +13,40 @@ interface Props {
const PrivatePortraitProjectList: React.FC<Props> = ({ items, selectedId, onSelect }) => { const PrivatePortraitProjectList: React.FC<Props> = ({ items, selectedId, onSelect }) => {
if (!items.length) return <Empty description="暂无项目组" />; if (!items.length) return <Empty description="暂无项目组" />;
return ( return (
<List <Space direction="vertical" style={{ width: '100%' }} size={10}>
dataSource={items} {items.map((project) => {
renderItem={(item) => ( const active = selectedId === project.id;
<List.Item style={{ padding: 0, marginBottom: 8 }}> return (
<Button <div
block key={project.id}
onClick={() => onSelect(item)} onClick={() => onSelect(project)}
style={{ height: 'auto', padding: 12, textAlign: 'left', borderColor: selectedId === item.id ? '#8b5cf6' : '#e2e8f0' }} style={{
padding: 14,
borderRadius: 14,
cursor: 'pointer',
border: active ? '1px solid #8b5cf6' : '1px solid #e2e8f0',
background: active ? '#f5f3ff' : '#fff',
}}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}> <Space style={{ width: '100%', justifyContent: 'space-between' }} align="start">
<strong>{item.name}</strong> <div style={{ minWidth: 0 }}>
<Tag color={item.activeAssetCount > 0 ? 'green' : 'default'}>{item.activeAssetCount}/{item.assetCount}</Tag> <Text strong ellipsis style={{ display: 'block' }}>{project.name}</Text>
</div> {project.description && <Text type="secondary" ellipsis style={{ display: 'block', fontSize: 12 }}>{project.description}</Text>}
{item.description && <div style={{ color: '#64748b', fontSize: 12, marginTop: 4 }}>{item.description}</div>} </div>
</Button> {/* <Tag color={project.status === 'active' ? 'green' : 'processing'}>
</List.Item> {project.status === 'active' ? '可用' : project.status}
)} </Tag> */}
/> </Space>
<Space wrap size={4} style={{ marginTop: 10 }}>
<Tag> {project.assetCount || 0}</Tag>
<Tag color="green"> {project.imageAssetCount || 0}</Tag>
<Tag color="blue"> {project.videoAssetCount || 0}</Tag>
{/* <Tag color="success">Active {project.activeAssetCount || 0}</Tag> */}
</Space>
</div>
);
})}
</Space>
); );
}; };
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Button, Card, Col, Form, Input, Modal, QRCode, Row, Space, Spin, Typography, message } from 'antd'; import { Button, Card, Col, Empty, Form, Input, Modal, QRCode, Row, Space, Spin, Typography, message } from 'antd';
import { CheckCircleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import { CheckCircleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import type { PrivatePortraitProject, PrivatePortraitValidateSession } from '../../../types'; import type { PrivatePortraitProject, PrivatePortraitValidateSession } from '../../../types';
import { createPrivatePortraitProject, getPrivatePortraitProjects, getPrivatePortraitValidateSession } from '../../../api'; import { createPrivatePortraitProject, getPrivatePortraitProjects, getPrivatePortraitValidateSession } from '../../../api';
@@ -117,27 +117,36 @@ const RealPersonLibraryPanel: React.FC = () => {
return ( return (
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<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>
<Space>
<Button icon={<ReloadOutlined />} onClick={loadProjects} loading={loading}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}></Button>
</Space>
</div> </div>
<Row gutter={16}> <Row gutter={16}>
<Col xs={24} md={7} lg={6}> <Col xs={24} lg={7}>
<Card title="项目组" style={{ borderRadius: 12 }}> <Card
<PrivatePortraitProjectList items={projects} selectedId={selected?.id} onSelect={setSelected} /> title={<span></span>}
extra={(
<Space size={8}>
{/* <Button icon={<ReloadOutlined />} onClick={loadProjects} loading={loading} size="small">刷新</Button> */}
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)} size="small"></Button>
</Space>
)}
style={{ borderRadius: 16, minHeight: 520 }}
>
<Spin spinning={loading}>
{projects.length === 0 ? (
<Empty description="暂无项目组" />
) : (
<PrivatePortraitProjectList items={projects} selectedId={selected?.id} onSelect={setSelected} />
)}
</Spin>
</Card> </Card>
</Col> </Col>
<Col xs={24} md={17} lg={18}> <Col xs={24} lg={17}>
{selected ? ( {selected ? (
<PrivatePortraitProjectDetail project={selected} onDeleted={() => { setSelected(null); loadProjects(); }} onChanged={loadProjects} /> <PrivatePortraitProjectDetail project={selected} onDeleted={() => { setSelected(null); loadProjects(); }} onChanged={loadProjects} />
) : ( ) : (
<Card style={{ borderRadius: 12, textAlign: 'center', color: '#94a3b8' }}></Card> <Card style={{ borderRadius: 16, minHeight: 520, textAlign: 'center', color: '#94a3b8' }}></Card>
)} )}
</Col> </Col>
</Row> </Row>
@@ -178,7 +187,7 @@ const RealPersonLibraryPanel: React.FC = () => {
{isSuccess ? '认证成功,项目组正在刷新' : '请使用手机扫码完成人脸认证,成功后回到电脑端查看项目组。'} {isSuccess ? '认证成功,项目组正在刷新' : '请使用手机扫码完成人脸认证,成功后回到电脑端查看项目组。'}
</Typography.Text> </Typography.Text>
</div> </div>
{h5Link && !isSuccess && <Typography.Text copyable style={{ wordBreak: 'break-all' }}>{h5Link}</Typography.Text>} {/* {h5Link && !isSuccess && <Typography.Text copyable style={{ wordBreak: 'break-all' }}>{h5Link}</Typography.Text>} */}
</Space> </Space>
)} )}
</Modal> </Modal>
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { import {
App, App,
Button, Button,
@@ -21,12 +21,10 @@ import {
} from 'antd'; } from 'antd';
import type { UploadFile } from 'antd/es/upload/interface'; import type { UploadFile } from 'antd/es/upload/interface';
import { import {
CloudSyncOutlined,
DeleteOutlined, DeleteOutlined,
EyeOutlined, EyeOutlined,
PictureOutlined, PictureOutlined,
PlusOutlined, PlusOutlined,
ReloadOutlined,
UploadOutlined, UploadOutlined,
VideoCameraOutlined, VideoCameraOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
@@ -36,15 +34,13 @@ import {
deletePrivatePortraitVirtualAsset, deletePrivatePortraitVirtualAsset,
deletePrivatePortraitVirtualProject, deletePrivatePortraitVirtualProject,
getPrivatePortraitVirtualAssets, getPrivatePortraitVirtualAssets,
getPrivatePortraitVirtualConfig,
getPrivatePortraitVirtualProjects, getPrivatePortraitVirtualProjects,
syncPrivatePortraitVirtualAsset,
uploadImage, uploadImage,
uploadVideo, uploadVideo,
} from '../../../api'; } from '../../../api';
import type { PrivatePortraitAsset, PrivatePortraitConfig, PrivatePortraitProject } from '../../../types'; import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types';
const { Text, Paragraph } = Typography; const { Text } = Typography;
type AssetTypeFilter = 'Image' | 'Video' | undefined; type AssetTypeFilter = 'Image' | 'Video' | undefined;
@@ -53,39 +49,18 @@ const MAX_PRIVATE_VIDEO_DURATION = 15;
const statusConfig: Record<string, { label: string; color: string }> = { const statusConfig: Record<string, { label: string; color: string }> = {
creating: { label: '本地创建中', color: 'processing' }, creating: { label: '本地创建中', color: 'processing' },
Processing: { label: '火山处理中', color: 'processing' }, Processing: { label: '入库处理中', color: 'processing' },
Active: { label: '可用于生成', color: 'success' }, Active: { label: '入库成功', color: 'success' },
Failed: { label: '入库失败', color: 'error' }, Failed: { label: '入库失败', color: 'error' },
local_deleted: { label: '本地已删', color: 'default' }, local_deleted: { label: '本地已删', color: 'default' },
remote_deleted: { label: '远端已删', color: 'default' }, remote_deleted: { label: '远端已删', color: 'default' },
delete_failed: { label: '远端删除失败', color: 'error' }, delete_failed: { label: '远端删除失败', color: 'error' },
}; };
const assetTypeConfig: Record<string, { label: string; color: string; icon: React.ReactNode }> = { const formatDuration = (value?: number | null) => {
Image: { label: '图片', color: 'green', icon: <PictureOutlined /> }, const duration = Number(value || 0);
Video: { label: '视频', color: 'blue', icon: <VideoCameraOutlined /> }, if (!Number.isFinite(duration) || duration <= 0) return '-';
}; return `${duration.toFixed(duration >= 10 ? 0 : 1)}s`;
const formatDateTime = (dateStr?: string | null) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
if (Number.isNaN(date.getTime())) return '-';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
const formatSize = (size?: number | null) => {
const value = Number(size || 0);
if (!value) return '-';
if (value >= 1024 * 1024 * 1024) return `${(value / 1024 / 1024 / 1024).toFixed(2)} GB`;
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(2)} MB`;
if (value >= 1024) return `${(value / 1024).toFixed(2)} KB`;
return `${value} B`;
}; };
const buildPreviewUrl = (url?: string | null) => { const buildPreviewUrl = (url?: string | null) => {
@@ -154,15 +129,8 @@ const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
return <Tag color={config?.color || 'default'}>{config?.label || value}</Tag>; return <Tag color={config?.color || 'default'}>{config?.label || value}</Tag>;
}; };
const TypeTag: React.FC<{ type?: string | null }> = ({ type }) => {
const value = type || '-';
const config = assetTypeConfig[value];
return <Tag color={config?.color || 'default'} icon={config?.icon}>{config?.label || value}</Tag>;
};
const VirtualMaterialPanel: React.FC = () => { const VirtualMaterialPanel: React.FC = () => {
const { message } = App.useApp(); const { message } = App.useApp();
const [config, setConfig] = useState<PrivatePortraitConfig | null>(null);
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]); const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string>(); const [selectedProjectId, setSelectedProjectId] = useState<string>();
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]); const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
@@ -184,26 +152,13 @@ const VirtualMaterialPanel: React.FC = () => {
const [previewUrl, setPreviewUrl] = useState(''); const [previewUrl, setPreviewUrl] = useState('');
const [previewType, setPreviewType] = useState<'Image' | 'Video'>('Image'); const [previewType, setPreviewType] = useState<'Image' | 'Video'>('Image');
const [createForm] = Form.useForm<{ name: string; description?: string }>(); const [createForm] = Form.useForm<{ name: string; description?: string }>();
const pollingRef = useRef<number | null>(null);
const selectedProject = useMemo( const selectedProject = useMemo(
() => projects.find((item) => item.id === selectedProjectId) || null, () => projects.find((item) => item.id === selectedProjectId) || null,
[projects, selectedProjectId], [projects, selectedProjectId],
); );
const quotaText = useMemo(() => {
if (!config) return '额度加载中';
return `已用 ${config.usedAssetCount || 0} / ${config.assetLimit || 0} 个素材,剩余 ${config.remainingAssetCount || 0}`;
}, [config]);
const loadConfig = async () => {
try {
const next = await getPrivatePortraitVirtualConfig();
setConfig(next);
} catch (err: any) {
message.error(err?.message || '加载私域素材额度失败');
}
};
const loadProjects = async () => { const loadProjects = async () => {
setProjectLoading(true); setProjectLoading(true);
try { try {
@@ -245,7 +200,7 @@ const VirtualMaterialPanel: React.FC = () => {
}; };
const reloadAll = async () => { const reloadAll = async () => {
await Promise.all([loadConfig(), loadProjects()]); await loadProjects();
}; };
useEffect(() => { useEffect(() => {
@@ -256,6 +211,32 @@ const VirtualMaterialPanel: React.FC = () => {
if (selectedProjectId) loadAssets(1, assetPageSize); if (selectedProjectId) loadAssets(1, assetPageSize);
}, [selectedProjectId]); }, [selectedProjectId]);
useEffect(() => {
const needsPolling = assets.some(
(asset) => asset.status !== 'Failed' && asset.status !== 'Active'
);
if (needsPolling && selectedProjectId) {
if (!pollingRef.current) {
pollingRef.current = window.setInterval(() => {
loadAssets(assetPage, assetPageSize);
}, 3000);
}
} else {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
}
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
};
}, [assets, selectedProjectId, assetPage, assetPageSize]);
const handleCreateProject = async () => { const handleCreateProject = async () => {
const values = await createForm.validateFields(); const values = await createForm.validateFields();
setCreatingProject(true); setCreatingProject(true);
@@ -308,7 +289,7 @@ const VirtualMaterialPanel: React.FC = () => {
setUploadOpen(false); setUploadOpen(false);
setFileList([]); setFileList([]);
setAssetName(''); setAssetName('');
await Promise.all([loadConfig(), loadProjects(), loadAssets(1, assetPageSize)]); await Promise.all([loadProjects(), loadAssets(1, assetPageSize)]);
} catch (err: any) { } catch (err: any) {
message.error(err?.message || '上传素材失败'); message.error(err?.message || '上传素材失败');
} finally { } finally {
@@ -316,21 +297,11 @@ const VirtualMaterialPanel: React.FC = () => {
} }
}; };
const handleSyncAsset = async (assetId: string) => {
try {
await syncPrivatePortraitVirtualAsset(assetId);
message.success('素材状态已刷新');
await Promise.all([loadConfig(), loadProjects(), loadAssets(assetPage, assetPageSize)]);
} catch (err: any) {
message.error(err?.message || '刷新素材状态失败');
}
};
const handleDeleteAsset = async (assetId: string) => { const handleDeleteAsset = async (assetId: string) => {
try { try {
await deletePrivatePortraitVirtualAsset(assetId); await deletePrivatePortraitVirtualAsset(assetId);
message.success('素材已删除,远端删除将异步执行'); message.success('素材已删除,远端删除将异步执行');
await Promise.all([loadConfig(), loadProjects(), loadAssets(assetPage, assetPageSize)]); await Promise.all([loadProjects(), loadAssets(assetPage, assetPageSize)]);
} catch (err: any) { } catch (err: any) {
message.error(err?.message || '删除素材失败'); message.error(err?.message || '删除素材失败');
} }
@@ -370,36 +341,40 @@ const VirtualMaterialPanel: React.FC = () => {
style={{ borderRadius: 16, overflow: 'hidden', borderColor: '#eef2f7' }} style={{ borderRadius: 16, overflow: 'hidden', borderColor: '#eef2f7' }}
cover={( cover={(
<div style={{ height: 170, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}> <div style={{ height: 170, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
{preview && !isVideo ? ( {preview ? (
<img src={preview} alt={asset.name || '虚拟人像素材'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> isVideo && asset.videoCoverUrl ? (
) : preview && isVideo && asset.videoCoverUrl ? ( <img src={preview} alt={asset.name || '视频封面'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<img src={preview} alt={asset.name || '视频封面'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> ) : isVideo ? (
<video src={preview} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<img src={preview} alt={asset.name || '虚拟人像素材'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
)
) : isVideo ? ( ) : isVideo ? (
<VideoCameraOutlined style={{ fontSize: 42, color: '#64748b' }} /> <VideoCameraOutlined style={{ fontSize: 42, color: '#64748b' }} />
) : ( ) : (
<PictureOutlined style={{ fontSize: 42, color: '#64748b' }} /> <PictureOutlined style={{ fontSize: 42, color: '#64748b' }} />
)} )}
{isVideo && <Tag color="blue" style={{ position: 'absolute', left: 10, top: 10 }}></Tag>} {isVideo && <Tag color="blue" style={{ position: 'absolute', left: 10, top: 10 }}></Tag>}
<Button size="small" shape="circle" icon={<EyeOutlined />} style={{ position: 'absolute', right: 10, top: 10 }} onClick={() => openPreview(asset)} /> {preview && (
<Button size="small" shape="circle" icon={<EyeOutlined />} style={{ position: 'absolute', right: 10, top: 10 }} onClick={() => openPreview(asset)} />
)}
{isVideo && (
<div style={{ position: 'absolute', left: 10, bottom: 10, padding: '2px 6px', borderRadius: 8, background: 'rgba(15,23,42,0.72)', color: '#fff', fontSize: 12 }}>
{formatDuration(asset.videoDuration)}
</div>
)}
</div> </div>
)} )}
> >
<Space direction="vertical" size={8} style={{ width: '100%' }}> <Space direction="vertical" size={8} style={{ width: '100%' }}>
<Tooltip title={asset.name || asset.remoteAssetId || asset.id}> <Tooltip title={asset.name || asset.remoteAssetId || asset.id}>
<Text strong ellipsis style={{ display: 'block' }}>{asset.name || asset.remoteAssetId || '未命名素材'}</Text> <div style={{ fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{asset.name || asset.remoteAssetId || '未命名素材'}</div>
</Tooltip> </Tooltip>
<Space wrap size={4}> <Space wrap size={4}>
<TypeTag type={asset.assetType} />
<StatusTag status={asset.status} /> <StatusTag status={asset.status} />
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
</Space> </Space>
<div style={{ color: '#64748b', fontSize: 12, lineHeight: 1.7 }}>
<div>{formatSize(asset.fileSize)}</div>
<div>{asset.pollCount || 0} </div>
<div>{formatDateTime(asset.createdAt)}</div>
</div>
{asset.errorMessage && <div style={{ color: '#ef4444', fontSize: 12 }}>{asset.errorMessage}</div>}
<Space size={6} wrap> <Space size={6} wrap>
<Button size="small" icon={<CloudSyncOutlined />} onClick={() => handleSyncAsset(asset.id)}></Button>
<Popconfirm title="确认删除这个虚拟人像素材吗?" onConfirm={() => handleDeleteAsset(asset.id)}> <Popconfirm title="确认删除这个虚拟人像素材吗?" onConfirm={() => handleDeleteAsset(asset.id)}>
<Button size="small" danger icon={<DeleteOutlined />}></Button> <Button size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm> </Popconfirm>
@@ -411,30 +386,6 @@ const VirtualMaterialPanel: React.FC = () => {
return ( return (
<div> <div>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} md={8}>
<Card style={{ borderRadius: 16, background: 'linear-gradient(135deg,#f5f3ff,#fff)' }}>
<Text type="secondary"></Text>
<div style={{ fontSize: 24, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>//</Paragraph>
</Card>
</Col>
<Col xs={24} md={8}>
<Card style={{ borderRadius: 16 }}>
<Text type="secondary"></Text>
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{projects.length}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}> AIGC Asset Group</Paragraph>
</Card>
</Col>
<Col xs={24} md={8}>
<Card style={{ borderRadius: 16 }}>
<Text type="secondary"></Text>
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{selectedProject?.assetCount || 0}</div>
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}> Active AI </Paragraph>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col xs={24} lg={7}> <Col xs={24} lg={7}>
<Card <Card
@@ -466,13 +417,13 @@ const VirtualMaterialPanel: React.FC = () => {
<Text strong ellipsis style={{ display: 'block' }}>{project.name}</Text> <Text strong ellipsis style={{ display: 'block' }}>{project.name}</Text>
{project.description && <Text type="secondary" ellipsis style={{ display: 'block', fontSize: 12 }}>{project.description}</Text>} {project.description && <Text type="secondary" ellipsis style={{ display: 'block', fontSize: 12 }}>{project.description}</Text>}
</div> </div>
<StatusTag status={project.status} /> {/* <StatusTag status={project.status} /> */}
</Space> </Space>
<Space wrap size={4} style={{ marginTop: 10 }}> <Space wrap size={4} style={{ marginTop: 10 }}>
<Tag> {project.assetCount || 0}</Tag> <Tag> {project.assetCount || 0}</Tag>
<Tag color="green"> {project.imageAssetCount || 0}</Tag> <Tag color="green"> {project.imageAssetCount || 0}</Tag>
<Tag color="blue"> {project.videoAssetCount || 0}</Tag> <Tag color="blue"> {project.videoAssetCount || 0}</Tag>
<Tag color="success">Active {project.activeAssetCount || 0}</Tag> {/* <Tag color="success">Active {project.activeAssetCount || 0}</Tag> */}
</Space> </Space>
</div> </div>
); );
@@ -488,7 +439,7 @@ const VirtualMaterialPanel: React.FC = () => {
title={selectedProject ? selectedProject.name : '素材资产'} title={selectedProject ? selectedProject.name : '素材资产'}
extra={( extra={(
<Space wrap> <Space wrap>
<Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(assetPage, assetPageSize); }} loading={assetLoading}></Button> {/* <Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(assetPage, assetPageSize); }} loading={assetLoading}>刷新</Button> */}
<Button type="primary" icon={<UploadOutlined />} disabled={!selectedProjectId} onClick={() => setUploadOpen(true)}>/</Button> <Button type="primary" icon={<UploadOutlined />} disabled={!selectedProjectId} onClick={() => setUploadOpen(true)}>/</Button>
{selectedProjectId && ( {selectedProjectId && (
<Popconfirm title="确认删除当前虚拟人像项目组吗?" onConfirm={handleDeleteProject}> <Popconfirm title="确认删除当前虚拟人像项目组吗?" onConfirm={handleDeleteProject}>
@@ -20,6 +20,13 @@ interface PrivatePortraitAssetPickerProps {
maxCount?: number; maxCount?: number;
onClose: () => void; onClose: () => void;
onSelect: (assets: PrivatePortraitSelectableAsset[]) => void; onSelect: (assets: PrivatePortraitSelectableAsset[]) => void;
maxImageCount?: number;
maxVideoCount?: number;
usedImageCount?: number;
usedVideoCount?: number;
usedVideoDuration?: number;
maxVideoDuration?: number;
accept?: string;
} }
const libraryMeta: Record<PrivatePortraitLibraryType, { title: string; empty: string; projectError: string; assetError: string; fallbackName: string }> = { const libraryMeta: Record<PrivatePortraitLibraryType, { title: string; empty: string; projectError: string; assetError: string; fallbackName: string }> = {
@@ -68,12 +75,19 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
maxCount = 20, maxCount = 20,
onClose, onClose,
onSelect, onSelect,
maxImageCount,
maxVideoCount,
usedImageCount,
usedVideoCount,
usedVideoDuration,
maxVideoDuration,
accept,
}) => { }) => {
const meta = libraryMeta[libraryType]; const meta = libraryMeta[libraryType];
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]); const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [projectId, setProjectId] = useState<string | undefined>(); const [projectId, setProjectId] = useState<string | undefined>();
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [assetType, setAssetType] = useState<AssetTypeFilter>(); const [assetType, setAssetType] = useState<AssetTypeFilter>(accept === 'image/*' ? 'Image' : undefined);
const [assets, setAssets] = useState<PrivatePortraitSelectableAsset[]>([]); const [assets, setAssets] = useState<PrivatePortraitSelectableAsset[]>([]);
const [selectedAssets, setSelectedAssets] = useState<Map<string, PrivatePortraitSelectableAsset>>(new Map()); const [selectedAssets, setSelectedAssets] = useState<Map<string, PrivatePortraitSelectableAsset>>(new Map());
const [loadingProjects, setLoadingProjects] = useState(false); const [loadingProjects, setLoadingProjects] = useState(false);
@@ -89,8 +103,8 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
const next = res.items || []; const next = res.items || [];
setProjects(next); setProjects(next);
setProjectId((prev) => (prev && next.some((item) => item.id === prev) ? prev : next[0]?.id)); setProjectId((prev) => (prev && next.some((item) => item.id === prev) ? prev : next[0]?.id));
} catch (err: any) { } catch (err: unknown) {
message.error(err?.message || meta.projectError); message.error((err as { message?: string })?.message || meta.projectError);
} finally { } finally {
setLoadingProjects(false); setLoadingProjects(false);
} }
@@ -108,8 +122,8 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
pageSize: 100, pageSize: 100,
}); });
setAssets(res.items || []); setAssets(res.items || []);
} catch (err: any) { } catch (err: unknown) {
message.error(err?.message || meta.assetError); message.error((err as { message?: string })?.message || meta.assetError);
} finally { } finally {
setLoadingAssets(false); setLoadingAssets(false);
} }
@@ -117,24 +131,32 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setSelectedAssets(new Map()); setTimeout(() => {
setKeyword(''); setSelectedAssets(new Map());
setAssetType(undefined); setKeyword('');
setProjectId(undefined); setAssetType(undefined);
setAssets([]); setProjectId(undefined);
loadProjects(); setAssets([]);
loadProjects();
}, 0);
}, [open, libraryType]); }, [open, libraryType]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
loadAssets(); setTimeout(() => {
}, [open, projectId, assetType]); loadAssets();
}, 0);
}, [open, projectId, assetType, libraryType]);
const toggle = (asset: PrivatePortraitSelectableAsset) => { const toggle = (asset: PrivatePortraitSelectableAsset) => {
if (selectedIds.includes(asset.id)) { if (selectedIds.includes(asset.id)) {
message.info('该素材已添加'); message.info('该素材已添加');
return; return;
} }
if (accept === 'image/*' && asset.assetType === 'Video') {
message.warning('当前仅支持选择图片素材');
return;
}
setSelectedAssets((prev) => { setSelectedAssets((prev) => {
const next = new Map(prev); const next = new Map(prev);
if (next.has(asset.id)) { if (next.has(asset.id)) {
@@ -150,6 +172,20 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
}); });
}; };
const isExceeded = useMemo(() => {
const selectedImages = Array.from(selectedAssets.values()).filter(a => a.assetType === 'Image').length;
const selectedVideos = Array.from(selectedAssets.values()).filter(a => a.assetType === 'Video').length;
const selectedVideoDuration = Array.from(selectedAssets.values())
.filter(a => a.assetType === 'Video')
.reduce((sum, a) => sum + (a.videoDuration || 0), 0);
const maxAvailableImages = maxImageCount !== undefined && usedImageCount !== undefined ? maxImageCount - usedImageCount : Infinity;
const maxAvailableVideos = maxVideoCount !== undefined && usedVideoCount !== undefined ? maxVideoCount - usedVideoCount : Infinity;
const maxAvailableDuration = maxVideoDuration !== undefined && usedVideoDuration !== undefined ? maxVideoDuration - usedVideoDuration : Infinity;
return selectedImages > maxAvailableImages || selectedVideos > maxAvailableVideos || selectedVideoDuration > maxAvailableDuration;
}, [selectedAssets, maxImageCount, usedImageCount, maxVideoCount, usedVideoCount, maxVideoDuration, usedVideoDuration]);
const confirm = () => { const confirm = () => {
const selected = (Array.from(selectedAssets.values()) as PrivatePortraitSelectableAsset[]).filter((item) => !selectedIds.includes(item.id)); const selected = (Array.from(selectedAssets.values()) as PrivatePortraitSelectableAsset[]).filter((item) => !selectedIds.includes(item.id));
if (!selected.length) { if (!selected.length) {
@@ -162,20 +198,54 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
return ( return (
<Modal <Modal
title={meta.title} title={`${meta.title}`}
open={open} open={open}
onCancel={onClose} onCancel={onClose}
width={920} width={920}
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} disabled={isExceeded} style={{
background: isExceeded ? '#94a3b8' : '#8b5cf6',
opacity: isExceeded ? 0.6 : 1,
cursor: isExceeded ? 'not-allowed' : 'pointer',
}}>
{selectedAssets.size} {selectedAssets.size}
</Button>, </Button>,
]} ]}
> >
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 16, minHeight: 480 }}> {accept !== 'image/*' && (
<div style={{ border: '1px solid #eef0f4', borderRadius: 12, padding: 12, background: '#fafafa' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 20, marginBottom: 16, padding: '12px 16px', background: '#fafafa', borderRadius: 8 }}>
{(() => {
const selectedImages = Array.from(selectedAssets.values()).filter(a => a.assetType === 'Image').length;
const selectedVideos = Array.from(selectedAssets.values()).filter(a => a.assetType === 'Video').length;
const selectedVideoDuration = Array.from(selectedAssets.values())
.filter(a => a.assetType === 'Video')
.reduce((sum, a) => sum + (a.videoDuration || 0), 0);
const maxAvailableImages = maxImageCount !== undefined && usedImageCount !== undefined ? maxImageCount - usedImageCount : Infinity;
const maxAvailableVideos = maxVideoCount !== undefined && usedVideoCount !== undefined ? maxVideoCount - usedVideoCount : Infinity;
const maxAvailableDuration = maxVideoDuration !== undefined && usedVideoDuration !== undefined ? maxVideoDuration - usedVideoDuration : Infinity;
const imageExceeded = selectedImages > maxAvailableImages;
const videoExceeded = selectedVideos > maxAvailableVideos;
const durationExceeded = selectedVideoDuration > maxAvailableDuration;
return (
<>
<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>
</>
);
})()}
</div>
)}
<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', height: "100%" }}>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 12 }}> <Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 12 }}>
<Text strong></Text> <Text strong></Text>
<Button size="small" icon={<ReloadOutlined />} onClick={loadProjects} loading={loadingProjects} /> <Button size="small" icon={<ReloadOutlined />} onClick={loadProjects} loading={loadingProjects} />
@@ -199,9 +269,9 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
<Text strong ellipsis style={{ display: 'block' }}>{item.name}</Text> <Text strong ellipsis style={{ display: 'block' }}>{item.name}</Text>
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{ fontSize: 12 }}>
Active {item.activeAssetCount || 0} {/* {item.activeAssetCount || 0} */}
{typeof item.activeImageAssetCount === 'number' || typeof item.activeVideoAssetCount === 'number' {typeof item.activeImageAssetCount === 'number' || typeof item.activeVideoAssetCount === 'number'
? ` ·${item.activeImageAssetCount || 0} / 视 ${item.activeVideoAssetCount || 0}` ? `${item.activeImageAssetCount || 0} / 视 ${item.activeVideoAssetCount || 0}`
: ''} : ''}
</Text> </Text>
</div> </div>
@@ -211,7 +281,7 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
</Spin> </Spin>
</div> </div>
<div> <div style={{overflowY: 'auto', height: "100%" }}>
<Space style={{ width: '100%', marginBottom: 12 }}> <Space style={{ width: '100%', marginBottom: 12 }}>
<Input <Input
allowClear allowClear
@@ -222,12 +292,14 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
onPressEnter={loadAssets} onPressEnter={loadAssets}
/> />
<Select <Select
allowClear allowClear={accept !== 'image/*'}
placeholder="素材类型" placeholder="素材类型"
value={assetType} value={assetType}
onChange={setAssetType} onChange={setAssetType}
style={{ width: 116 }} style={{ width: 116 }}
options={[ options={accept === 'image/*' ? [
{ value: 'Image', label: '图片' },
] : [
{ value: 'Image', label: '图片' }, { value: 'Image', label: '图片' },
{ value: 'Video', label: '视频' }, { value: 'Video', label: '视频' },
]} ]}
@@ -239,7 +311,7 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={meta.empty} style={{ marginTop: 120 }} /> <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={meta.empty} style={{ marginTop: 120 }} />
) : ( ) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 12, maxHeight: 420, overflowY: 'auto', paddingRight: 4 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 12, maxHeight: 420, overflowY: 'auto', paddingRight: 4 }}>
{assets.map((asset) => { {assets.filter(asset => accept !== 'image/*' || asset.assetType === 'Image').map((asset) => {
const active = checked.has(asset.id) || selectedIds.includes(asset.id); const active = checked.has(asset.id) || selectedIds.includes(asset.id);
const disabled = selectedIds.includes(asset.id); const disabled = selectedIds.includes(asset.id);
const previewUrl = getAssetPreviewUrl(asset); const previewUrl = getAssetPreviewUrl(asset);
@@ -285,7 +357,7 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
<div style={{ padding: 10 }}> <div style={{ padding: 10 }}>
<Text strong ellipsis style={{ display: 'block' }}>{asset.name || '未命名素材'}</Text> <Text strong ellipsis style={{ display: 'block' }}>{asset.name || '未命名素材'}</Text>
<Space wrap size={4} style={{ marginTop: 6 }}> <Space wrap size={4} style={{ marginTop: 6 }}>
<Tag color="green">Active</Tag> {/* <Tag color="green">Active</Tag> */}
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag> <Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
<Tag>{asset.projectName}</Tag> <Tag>{asset.projectName}</Tag>
</Space> </Space>
+153 -68
View File
@@ -8,7 +8,6 @@ import {
Button, Button,
Input, Input,
Select, Select,
Upload,
message, message,
Space, Space,
Typography, Typography,
@@ -320,6 +319,28 @@ const AIChatPage: React.FC = () => {
const [attachmentPreviewType, setAttachmentPreviewType] = useState<'image' | 'video' | 'audio'>('image'); const [attachmentPreviewType, setAttachmentPreviewType] = useState<'image' | 'video' | 'audio'>('image');
const [attachmentPreviewName, setAttachmentPreviewName] = useState<string>(''); const [attachmentPreviewName, setAttachmentPreviewName] = useState<string>('');
const attachmentPreviewVideoRef = useRef<HTMLVideoElement>(null); const attachmentPreviewVideoRef = useRef<HTMLVideoElement>(null);
// 弹窗打开时自动播放视频,关闭时停止
useEffect(() => {
if (attachmentPreviewVisible && attachmentPreviewType === 'video' && attachmentPreviewVideoRef.current) {
const v = attachmentPreviewVideoRef.current;
v.currentTime = 0;
v.muted = false;
const playPromise = v.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => {
// 自动播放被阻止时静音重试
v.muted = true;
v.play().catch(() => {});
});
}
} else if (!attachmentPreviewVisible && attachmentPreviewVideoRef.current) {
const v = attachmentPreviewVideoRef.current;
v.pause();
v.muted = true;
v.currentTime = 0;
}
}, [attachmentPreviewVisible, attachmentPreviewType, attachmentPreviewUrl]);
const [playingAudioUrl, setPlayingAudioUrl] = useState<string | null>(null); const [playingAudioUrl, setPlayingAudioUrl] = useState<string | null>(null);
const [audioProgress, setAudioProgress] = useState(0); const [audioProgress, setAudioProgress] = useState(0);
@@ -387,24 +408,22 @@ const AIChatPage: React.FC = () => {
} }
} }
// 根据配置计算积分 // 根据配置计算积分(保留两位小数,不做取整)
if (mediaType === 'video') { if (mediaType === 'video') {
// 视频:(秒数 × perSecondCredits + baseCredits) × ratio // 视频:(秒数 × perSecondCredits + baseCredits) × ratio
let total = Math.round((videoDuration * config.perSecondCredits + config.baseCredits) * config.ratio); let total = (videoDuration * config.perSecondCredits + config.baseCredits) * config.ratio;
// 传入视频积分 // 传入视频积分
const inputVideoDuration = currentMedia const inputVideoDuration = currentMedia
.filter((m) => m.type === 'video') .filter((m) => m.type === 'video')
.reduce((sum, m) => sum + (m.duration || 0), 0); .reduce((sum, m) => sum + (m.duration || 0), 0);
if (inputVideoDuration > 0) { if (inputVideoDuration > 0) {
const inputVideoCost = Math.round( const inputVideoCost = ((config.inputVideoBaseCredits || 0) + (config.inputVideoPerSecondCredits || 0) * inputVideoDuration) * (config.inputVideoRatio || 1);
((config.inputVideoBaseCredits || 0) + (config.inputVideoPerSecondCredits || 0) * inputVideoDuration) * (config.inputVideoRatio || 1)
);
total += inputVideoCost; total += inputVideoCost;
} }
return total; return Number(total.toFixed(2));
} else { } else {
// 图片:baseCredits × ratio // 图片:baseCredits × ratio
return config.baseCredits * config.ratio; return Number((config.baseCredits * config.ratio).toFixed(2));
} }
}; };
@@ -412,6 +431,8 @@ const AIChatPage: React.FC = () => {
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const pollingRef = useRef<number | null>(null); const pollingRef = useRef<number | null>(null);
const isFirstLoadRef = useRef<boolean>(true); const isFirstLoadRef = useRef<boolean>(true);
const isInitialLoadDone = useRef(false);
const lastAutoScrollTime = useRef(0);
const currentConversation = conversations.find((c) => c.id === currentConversationId); const currentConversation = conversations.find((c) => c.id === currentConversationId);
@@ -424,7 +445,6 @@ const AIChatPage: React.FC = () => {
}); });
const [Totalnumber, setTotalnumber] = useState<any>(0); const [Totalnumber, setTotalnumber] = useState<any>(0);
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
const isInitialLoadDone = useRef(false);
// ==================== 副作用 ==================== // ==================== 副作用 ====================
// 滚动到底部的辅助函数 // 滚动到底部的辅助函数
@@ -440,6 +460,8 @@ const AIChatPage: React.FC = () => {
const scrollContainer = scrollContainerRef.current; const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return; if (!scrollContainer) return;
lastAutoScrollTime.current = Date.now();
const doScroll = () => { const doScroll = () => {
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });
}; };
@@ -507,9 +529,8 @@ const AIChatPage: React.FC = () => {
const supportsUR = engine.supportsUniversalReference ?? true; const supportsUR = engine.supportsUniversalReference ?? true;
setReferenceMode((prevMode) => { setReferenceMode((prevMode) => {
if (prevMode === 'first_last_frame' && !supportsFLF) { if (supportsUR) return 'universal';
if (supportsUR) return 'universal'; if (prevMode === 'universal' && !supportsUR) {
} else if (prevMode === 'universal' && !supportsUR) {
if (supportsFLF) return 'first_last_frame'; if (supportsFLF) return 'first_last_frame';
} }
return prevMode; return prevMode;
@@ -582,26 +603,9 @@ const AIChatPage: React.FC = () => {
{ value: '2K', label: '高清 2K' }, { value: '2K', label: '高清 2K' },
{ value: '4K', label: '超清 4K' }, { value: '4K', label: '超清 4K' },
]); ]);
}) })
.catch(() => { .catch(() => {
}); });
// getCreditRatios()
// .then((data: any) => {
// // console.log('积分', data);
// setCreditRatios(data.video || []);
// setCimage(data.image || []);
// })
// .catch((error) => {
// });
calculateCredits().then((data: any) => { calculateCredits().then((data: any) => {
// console.log('积分计算', data); // console.log('积分计算', data);
// 保存积分计算数据 // 保存积分计算数据
@@ -765,6 +769,8 @@ const AIChatPage: React.FC = () => {
if (mode === 'universal' && !supportsUniversalReference) return; if (mode === 'universal' && !supportsUniversalReference) return;
setReferenceMode(mode); setReferenceMode(mode);
setReferenceModeDropdownVisible(false); setReferenceModeDropdownVisible(false);
setCurrentMedia([]);
setInputValue('');
}; };
const handleSwapFrames = () => { const handleSwapFrames = () => {
@@ -1704,7 +1710,7 @@ const AIChatPage: React.FC = () => {
const composerPlaceholder = mediaType === 'image' const composerPlaceholder = mediaType === 'image'
? '输入画面描述,或上传图片参考风格、构图、主体与光影。例:@图片1 参考色调,生成一张简洁高级的产品海报。' ? '输入画面描述,或上传图片参考风格、构图、主体与光影。例:@图片1 参考色调,生成一张简洁高级的产品海报。'
: isFirstLastFrameComposer : isFirstLastFrameComposer
? '描述首帧到尾帧的主体动作、镜头运动、节奏与转场。例:从首帧自然推进到尾帧,产品居中突出,动作平滑连贯。' ? '首帧尾帧可以不传,如需传入建议描述首帧到尾帧的主体动作、镜头运动、节奏与转场。例:从首帧自然推进到尾帧,产品居中突出,动作平滑连贯。'
: '上传最多12个参考素材,输入文字或 @ 引用参考内容,自由组合图、文、视频。例:@图片1 模仿 @视频1 的动作。'; : '上传最多12个参考素材,输入文字或 @ 引用参考内容,自由组合图、文、视频。例:@图片1 模仿 @视频1 的动作。';
const composerHelperText = mediaType === 'image' const composerHelperText = mediaType === 'image'
? '图片参考 · 适合海报、产品图、场景图与风格图生成' ? '图片参考 · 适合海报、产品图、场景图与风格图生成'
@@ -1906,27 +1912,21 @@ const AIChatPage: React.FC = () => {
background: '#fff', background: '#fff',
// borderRadius: 22, // borderRadius: 22,
}} }}
onScroll={(e) => {
const target = e.currentTarget;
const now = Date.now();
const isAutoScroll = now - lastAutoScrollTime.current < 500;
if (!isAutoScroll && isInitialLoadDone.current && target.scrollTop <= 50 && !isLoadingMore && gen_list.length < Totalnumber) {
handleLoadMore();
}
}}
> >
{/* 加载更多按钮 - 在列表顶部 */} {/* 加载更多提示 */}
<div style={{ padding: '8px 0', textAlign: 'center', marginBottom: 16 }}> {isLoadingMore && (
{gen_list.length >= Totalnumber ? ( <div style={{ padding: '8px 0', textAlign: 'center', marginBottom: 16 }}>
<span style={{ fontSize: 12, color: '#98a2b3' }}></span> <span style={{ fontSize: 12, color: '#8b5cf6' }}>...</span>
) : ( </div>
<button )}
onClick={handleLoadMore}
disabled={isLoadingMore}
style={{
fontSize: 12,
color: '#8b5cf6',
background: 'none',
border: 'none',
cursor: isLoadingMore ? 'not-allowed' : 'pointer',
}}
>
{isLoadingMore ? '加载中...' : '加载更多'}
</button>
)}
</div>
{/* 遍历消息列表 */} {/* 遍历消息列表 */}
{(() => { {(() => {
const msgApi = message; const msgApi = message;
@@ -2226,7 +2226,7 @@ const AIChatPage: React.FC = () => {
<span style={{ <span style={{
// background: 'rgba(139, 92, 246, 0.08)', // background: 'rgba(139, 92, 246, 0.08)',
borderRadius: 16, color: '#667085' borderRadius: 16, color: '#667085'
}}>{msg.genType === 'image' ? `${msg.imageProportion || ''} · ${msg.imagePx || ''} · ${msg.imageSize || ''}` : `${msg.duration || ''}s · ${msg.aspectRatio || ''} · ${msg.resolution || ''}`}</span> }}>{msg.genType === 'image' ? `${msg.imageProportion || ''} · ${msg.imagePx || ''} · ${msg.imageSize || ''}` : `${msg.duration || ''} · ${msg.aspectRatio || ''} · ${msg.resolution || ''}`}</span>
<span style={{ <span style={{
// background: 'rgba(164, 91, 91, 0.08)', // background: 'rgba(164, 91, 91, 0.08)',
fontSize: 14, fontSize: 14,
@@ -2497,7 +2497,7 @@ const AIChatPage: React.FC = () => {
{/* 首帧 */} {/* 首帧 */}
<div style={{ position: 'relative', flex: 1, minWidth: 0, height: 100, borderRadius: 12, border: '1px solid #E7EAF0', background: '#ffffff', boxShadow: '0 6px 16px rgba(47, 52, 64, 0.04)', overflow: 'hidden' }}> <div style={{ position: 'relative', flex: 1, minWidth: 0, height: 100, borderRadius: 12, border: '1px solid #E7EAF0', background: '#ffffff', boxShadow: '0 6px 16px rgba(47, 52, 64, 0.04)', overflow: 'hidden' }}>
<div style={{ position: 'absolute', top: 8, left: 10, right: 10, display: 'flex', alignItems: 'center', justifyContent: 'space-between', zIndex: 2 }}> <div style={{ position: 'absolute', top: 8, left: 10, right: 10, display: 'flex', alignItems: 'center', justifyContent: 'space-between', zIndex: 2 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#8b5cf6', lineHeight: 1 }}></span> <span style={{ fontSize: 11, fontWeight: 600, color: '#8b5cf6', lineHeight: 1 }}>()</span>
</div> </div>
{firstFrame ? ( {firstFrame ? (
<div style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64 }}> <div style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64 }}>
@@ -2520,7 +2520,45 @@ const AIChatPage: React.FC = () => {
</button> </button>
</div> </div>
) : ( ) : (
<Upload accept="image/*" showUploadList={false} beforeUpload={(file) => handleUpload(file, 'first')}> <UploadSelector
accept="image/*"
multiple={false}
onLocalSelect={(files) => {
if (files.length > 0) {
handleUpload(files[0], 'first');
}
}}
onHistorySelect={(items) => {
if (items.length > 0) {
const item = items[0];
const mediaRef: MediaReference = {
name: item.name,
type: 'image',
url: item.url,
role: 'first_frame',
label: '',
};
setFirstFrame(mediaRef);
message.success('成功添加首帧');
}
}}
onPortraitSelect={(assets) => {
if (assets.length > 0) {
const asset = assets[0];
const previewUrl = asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.providerUrl || '';
const mediaRef: MediaReference = {
name: asset.name || '首帧图片',
type: 'image',
url: previewUrl,
role: 'first_frame',
label: '',
};
setFirstFrame(mediaRef);
message.success('成功添加首帧');
}
}}
uploading={uploading}
>
<div <div
style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64, borderRadius: 8, border: '1px dashed rgba(139, 92, 246, 0.28)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'all 0.25s ease', backgroundColor: 'rgba(139, 92, 246, 0.04)', flexDirection: 'column', gap: 4 }} style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64, borderRadius: 8, border: '1px dashed rgba(139, 92, 246, 0.28)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'all 0.25s ease', backgroundColor: 'rgba(139, 92, 246, 0.04)', flexDirection: 'column', gap: 4 }}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#a78bfa'; e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.08)'; }} onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#a78bfa'; e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.08)'; }}
@@ -2529,7 +2567,7 @@ const AIChatPage: React.FC = () => {
<PlusOutlined style={{ fontSize: 18, color: '#8b5cf6' }} /> <PlusOutlined style={{ fontSize: 18, color: '#8b5cf6' }} />
<span style={{ fontSize: 12, color: '#8b5cf6', fontWeight: 500 }}></span> <span style={{ fontSize: 12, color: '#8b5cf6', fontWeight: 500 }}></span>
</div> </div>
</Upload> </UploadSelector>
)} )}
</div> </div>
@@ -2572,7 +2610,45 @@ const AIChatPage: React.FC = () => {
</div> </div>
) : ( ) : (
firstFrame ? ( firstFrame ? (
<Upload accept="image/*" showUploadList={false} beforeUpload={(file) => handleUpload(file, 'last')}> <UploadSelector
accept="image/*"
multiple={false}
onLocalSelect={(files) => {
if (files.length > 0) {
handleUpload(files[0], 'last');
}
}}
onHistorySelect={(items) => {
if (items.length > 0) {
const item = items[0];
const mediaRef: MediaReference = {
name: item.name,
type: 'image',
url: item.url,
role: 'last_frame',
label: '',
};
setLastFrame(mediaRef);
message.success('成功添加尾帧');
}
}}
onPortraitSelect={(assets) => {
if (assets.length > 0) {
const asset = assets[0];
const previewUrl = asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.providerUrl || '';
const mediaRef: MediaReference = {
name: asset.name || '尾帧图片',
type: 'image',
url: previewUrl,
role: 'last_frame',
label: '',
};
setLastFrame(mediaRef);
message.success('成功添加尾帧');
}
}}
uploading={uploading}
>
<div <div
style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64, borderRadius: 8, border: '1px dashed rgba(139, 92, 246, 0.24)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'all 0.25s ease', backgroundColor: 'rgba(139, 92, 246, 0.04)', flexDirection: 'column', gap: 4 }} style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64, borderRadius: 8, border: '1px dashed rgba(139, 92, 246, 0.24)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'all 0.25s ease', backgroundColor: 'rgba(139, 92, 246, 0.04)', flexDirection: 'column', gap: 4 }}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#a78bfa'; e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.08)'; }} onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#a78bfa'; e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.08)'; }}
@@ -2581,7 +2657,7 @@ const AIChatPage: React.FC = () => {
<PlusOutlined style={{ fontSize: 18, color: '#8b5cf6' }} /> <PlusOutlined style={{ fontSize: 18, color: '#8b5cf6' }} />
<span style={{ fontSize: 12, color: '#8b5cf6', fontWeight: 500 }}></span> <span style={{ fontSize: 12, color: '#8b5cf6', fontWeight: 500 }}></span>
</div> </div>
</Upload> </UploadSelector>
) : ( ) : (
<div <div
style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64, borderRadius: 8, border: '1px dashed rgba(226, 232, 240, 0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'not-allowed', backgroundColor: 'rgba(241, 245, 249, 0.5)', flexDirection: 'column', gap: 4 }} style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64, borderRadius: 8, border: '1px dashed rgba(226, 232, 240, 0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'not-allowed', backgroundColor: 'rgba(241, 245, 249, 0.5)', flexDirection: 'column', gap: 4 }}
@@ -2644,23 +2720,23 @@ const AIChatPage: React.FC = () => {
justifyContent: 'center', justifyContent: 'center',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.25s ease', transition: 'all 0.25s ease',
background: '#ffffff', background: '#f4f4f4',
flexDirection: 'column', flexDirection: 'column',
gap: 5, gap: 5,
transform: 'rotate(-7deg)', transform: 'rotate(-7deg)',
boxShadow: '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)', // boxShadow: '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#D7DDE7'; e.currentTarget.style.borderColor = '#D7DDE7';
e.currentTarget.style.background = 'linear-gradient(180deg, #ffffff 0%, #F7F8FA 100%)'; e.currentTarget.style.background = '#f4f4f4';
e.currentTarget.style.transform = 'rotate(0deg) translateY(-2px)'; e.currentTarget.style.transform = 'rotate(0deg) translateY(-2px)';
e.currentTarget.style.boxShadow = '0 14px 28px rgba(47, 52, 64, 0.15), inset 0 1px 0 rgba(255,255,255,0.98)'; // e.currentTarget.style.boxShadow = '0 14px 28px rgba(47, 52, 64, 0.15), inset 0 1px 0 rgba(255,255,255,0.98)';
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(231, 234, 240, 0.95)'; e.currentTarget.style.borderColor = 'rgba(231, 234, 240, 0.95)';
e.currentTarget.style.background = 'linear-gradient(180deg, #ffffff 0%, #FAFBFC 100%)'; e.currentTarget.style.background = '#f4f4f4';
e.currentTarget.style.transform = 'rotate(-7deg)'; e.currentTarget.style.transform = 'rotate(-7deg)';
e.currentTarget.style.boxShadow = '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)'; // e.currentTarget.style.boxShadow = '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)';
}} }}
> >
{uploading ? ( {uploading ? (
@@ -3099,6 +3175,8 @@ const AIChatPage: React.FC = () => {
onClick={() => { onClick={() => {
setMediaType(item.value); setMediaType(item.value);
setShowMediaTypeModal(false); setShowMediaTypeModal(false);
setCurrentMedia([]);
setInputValue('');
}} }}
style={{ style={{
flex: 1, flex: 1,
@@ -3221,14 +3299,12 @@ const AIChatPage: React.FC = () => {
key={engine.id} key={engine.id}
onClick={() => { onClick={() => {
setCountType(engine.id); setCountType(engine.id);
// 根据选中的引擎更新视频参数选项
if (mediaType === 'video') { if (mediaType === 'video') {
setEngineOptions({ setEngineOptions({
ratios: engine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'], ratios: engine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
resolutions: engine.supportedResolutions || ['480p', '720p', '1080p'], resolutions: engine.supportedResolutions || ['480p', '720p', '1080p'],
durations: engine.supportedDurations || [5, 8, 10, 12, 15], durations: engine.supportedDurations || [5, 8, 10, 12, 15],
}); });
// 更新默认选中值,确保在新引擎支持的范围内
if (!engine.supportedRatios?.includes(videoAspectRatio)) { if (!engine.supportedRatios?.includes(videoAspectRatio)) {
setVideoAspectRatio(engine.supportedRatios?.[0] || '16:9'); setVideoAspectRatio(engine.supportedRatios?.[0] || '16:9');
} }
@@ -3240,6 +3316,8 @@ const AIChatPage: React.FC = () => {
} }
} }
setShowEngineModal(false); setShowEngineModal(false);
setCurrentMedia([]);
setInputValue('');
}} }}
style={{ style={{
flex: 1, flex: 1,
@@ -3798,7 +3876,7 @@ const AIChatPage: React.FC = () => {
<span style={{ width: 1, height: 14, background: '#E7EAF0' }} /> <span style={{ width: 1, height: 14, background: '#E7EAF0' }} />
<span style={{ fontSize: 13, fontWeight: 600, color: '#2f3440' }}>{videoResolution?.toUpperCase?.() || videoResolution}</span> <span style={{ fontSize: 13, fontWeight: 600, color: '#2f3440' }}>{videoResolution?.toUpperCase?.() || videoResolution}</span>
<span style={{ width: 1, height: 14, background: '#E7EAF0' }} /> <span style={{ width: 1, height: 14, background: '#E7EAF0' }} />
<span style={{ fontSize: 13, fontWeight: 600, color: '#2f3440' }}>{videoDuration}s</span> <span style={{ fontSize: 13, fontWeight: 600, color: '#2f3440' }}>{videoDuration}</span>
<CaretDownOutlined style={{ fontSize: 10, color: '#667085', marginLeft: 'auto' }} /> <CaretDownOutlined style={{ fontSize: 10, color: '#667085', marginLeft: 'auto' }} />
</button> </button>
@@ -4187,13 +4265,12 @@ const AIChatPage: React.FC = () => {
</div> </div>
} }
onCancel={() => { onCancel={() => {
// 关闭前强制停止视频播放,避免关闭后仍有声音 // 关闭前停止视频播放
if (attachmentPreviewVideoRef.current) { if (attachmentPreviewVideoRef.current) {
const v = attachmentPreviewVideoRef.current; const v = attachmentPreviewVideoRef.current;
v.pause(); v.pause();
v.muted = true; v.muted = true;
v.removeAttribute('src'); v.currentTime = 0;
v.load();
} }
setAttachmentPreviewVisible(false); setAttachmentPreviewVisible(false);
}} }}
@@ -4250,6 +4327,7 @@ const AIChatPage: React.FC = () => {
ref={attachmentPreviewVideoRef} ref={attachmentPreviewVideoRef}
src={buildPreviewUrl(attachmentPreviewUrl)} src={buildPreviewUrl(attachmentPreviewUrl)}
controls controls
playsInline
style={{ maxWidth: '100%', maxHeight: '400px' }} style={{ maxWidth: '100%', maxHeight: '400px' }}
/> />
)} )}
@@ -4262,6 +4340,13 @@ const AIChatPage: React.FC = () => {
onSelect={handlePrivatePortraitAssetsSelected} onSelect={handlePrivatePortraitAssetsSelected}
selectedIds={currentMedia.map((m) => m.private_asset_id).filter(Boolean) as string[]} selectedIds={currentMedia.map((m) => m.private_asset_id).filter(Boolean) as string[]}
maxCount={Math.max(1, maxImage + maxVideo)} maxCount={Math.max(1, maxImage + maxVideo)}
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}
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*'}
/> />
</Layout> </Layout>
+42 -6
View File
@@ -281,14 +281,42 @@ const GeneratePage: React.FC = () => {
const videoCount = references.filter( const videoCount = references.filter(
(r) => r.type === "video", (r) => r.type === "video",
).length; ).length;
if (isImage && imageCount >= 10) { const videoDuration = references
message.error("最多上传10张图片"); .filter((r) => r.type === "video")
.reduce((sum, r) => sum + (r.duration || 0), 0);
const MAX_IMAGES = 5;
const MAX_VIDEOS = 2;
const MAX_VIDEO_DURATION = 15;
if (isImage && imageCount >= MAX_IMAGES) {
message.error(`最多上传${MAX_IMAGES}张图片`);
return false; return false;
} }
if (isVideo && videoCount >= 3) { if (isVideo && videoCount >= MAX_VIDEOS) {
message.error("最多上传3个视频"); message.error(`最多上传${MAX_VIDEOS}个视频`);
return false; return false;
} }
let fileDuration = 0;
if (isVideo) {
fileDuration = await 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);
});
if (videoDuration + fileDuration > MAX_VIDEO_DURATION) {
message.error(`视频总时长不能超过${MAX_VIDEO_DURATION}`);
return false;
}
}
setUploading(true); setUploading(true);
const uploadFn = isImage ? uploadImage : uploadVideo; const uploadFn = isImage ? uploadImage : uploadVideo;
try { try {
@@ -303,6 +331,7 @@ const GeneratePage: React.FC = () => {
url: res.url, url: res.url,
type: isImage ? "image" : "video", type: isImage ? "image" : "video",
name: `${typeLabel}${typeCount}`, name: `${typeLabel}${typeCount}`,
duration: isVideo ? fileDuration : undefined,
}, },
]); ]);
message.success(`${typeLabel}上传成功`); message.success(`${typeLabel}上传成功`);
@@ -1842,17 +1871,24 @@ const GeneratePage: React.FC = () => {
items.forEach((item: any) => { items.forEach((item: any) => {
setReferences(prev => [...prev, { setReferences(prev => [...prev, {
url: item.previewUrl || '', url: item.previewUrl || '',
type: 'image', type: item.assetType === 'Video' ? 'video' : 'image',
name: item.name || '真人素材', name: item.name || '真人素材',
source: 'private_portrait_asset', source: 'private_portrait_asset',
private_asset_id: item.id, private_asset_id: item.id,
label: '', label: '',
duration: item.assetType === 'Video' ? (item.videoDuration || 0) : undefined,
}]); }]);
}); });
message.success(`已添加 ${items.length} 个真人素材参考`); message.success(`已添加 ${items.length} 个真人素材参考`);
}} }}
uploading={uploading} uploading={uploading}
tooltipTitle={`参考内容(${references.length}/10`} tooltipTitle={`图片${references.filter((r) => r.type === 'image').length}/5,视频${references.filter((r) => r.type === 'video').length}/2`}
maxImageCount={5}
maxVideoCount={2}
usedImageCount={references.filter((r) => r.type === 'image').length}
usedVideoCount={references.filter((r) => r.type === 'video').length}
usedVideoDuration={references.filter((r) => r.type === 'video').reduce((sum, r) => sum + (r.duration || 0), 0)}
maxVideoDuration={15}
> >
<div <div
style={{ style={{
+1 -1
View File
@@ -916,7 +916,7 @@ const GeneratedRecord: React.FC = () => {
})); }));
}, [filterType, filterMedia]); }, [filterType, filterMedia]);
return ( return (
<div style={{ minHeight: 'calc(100vh - 90px)', background: '#ffffffff', overflowY: 'auto' }} > <div className="content_box" >
{/* 操作栏:筛选 + 推送按钮 */} {/* 操作栏:筛选 + 推送按钮 */}
<div style={{ <div style={{
display: 'flex', display: 'flex',
+18 -9
View File
@@ -67,7 +67,7 @@ const HomePage: React.FC = () => {
const fetchAll = async () => { const fetchAll = async () => {
try { try {
getmedit(5).then((res) => { getmedit(10).then((res) => {
for (let item in res) { for (let item in res) {
res[item].forEach(element => { res[item].forEach(element => {
// 给每条 element 标记它所属的模块(item 是接口返回的 key) // 给每条 element 标记它所属的模块(item 是接口返回的 key)
@@ -479,10 +479,9 @@ const HomePage: React.FC = () => {
onClick={() => navigate(entry.path)} onClick={() => navigate(entry.path)}
className="project-card" className="project-card"
style={{ style={{
flex: '1', flex: '1 1 280px',
minWidth: 260, minWidth: 280,
height: 120, padding: '16px 20px',
padding: '16px',
borderRadius: 16, borderRadius: 16,
background: '#fff', background: '#fff',
border: '1px solid #e2e8f0', border: '1px solid #e2e8f0',
@@ -492,6 +491,7 @@ const HomePage: React.FC = () => {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 14, gap: 14,
minHeight: 96,
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.borderColor = accent.color; e.currentTarget.style.borderColor = accent.color;
@@ -529,18 +529,27 @@ const HomePage: React.FC = () => {
> >
<span style={{ fontSize: 20, display: 'flex', transition: 'all 0.3s ease' }}>{entry.icon}</span> <span style={{ fontSize: 20, display: 'flex', transition: 'all 0.3s ease' }}>{entry.icon}</span>
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 15, fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> <span style={{ fontSize: 15, fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1, minWidth: 0 }}>
{entry.title} {entry.title}
</span> </span>
<span style={{ <span style={{
fontSize: 10, color: accent.color, fontSize: 10, color: accent.color,
padding: '2px 7px', borderRadius: 4, padding: '2px 7px', borderRadius: 4,
background: accent.light, fontWeight: 600, background: accent.light, fontWeight: 600,
flexShrink: 0,
}}>{accent.tag}</span> }}>{accent.tag}</span>
</div> </div>
<div style={{ fontSize: 12, color: '#64748b', }}> <div style={{
fontSize: 12,
color: '#64748b',
lineHeight: 1.5,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}>
{entry.description} {entry.description}
</div> </div>
</div> </div>
+57 -19
View File
@@ -10,6 +10,7 @@ import {
Table, Table,
Space, Space,
Pagination, Pagination,
Popconfirm,
} from 'antd'; } from 'antd';
import { import {
PlusOutlined, PlusOutlined,
@@ -18,7 +19,7 @@ import {
LoadingOutlined, LoadingOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { uploadVideo, uploadImage, generateReplication, getReplicationList, getone, getReplicationDetail } from '../api'; import { uploadVideo, uploadImage, generateReplication, getReplicationList, getone, getReplicationDetail, deleteHotOpeningReplicationTask } from '../api';
const { Header, Content } = Layout; const { Header, Content } = Layout;
const { TextArea } = Input; const { TextArea } = Input;
@@ -1206,7 +1207,7 @@ const GenerateConver: React.FC = () => {
dataIndex: 'targetProjectName', dataIndex: 'targetProjectName',
key: 'targetProjectName', key: 'targetProjectName',
align: 'center', align: 'center',
width: 200, width: 100,
render: (text: string) => ( render: (text: string) => (
<span style={{ fontSize: 13, color: '#1e293b', fontWeight: 500 }}> <span style={{ fontSize: 13, color: '#1e293b', fontWeight: 500 }}>
{text || '-'} {text || '-'}
@@ -1329,24 +1330,61 @@ const GenerateConver: React.FC = () => {
title: '操作', title: '操作',
key: 'action', key: 'action',
align: 'center', align: 'center',
width: 120, width: 220,
render: (_, record) => ( render: (_, record) => (
<button <Space>
onClick={() => navigate(`/initial/${record.id}/initialinfo`)} <button
style={{ onClick={() => navigate(`/initial/${record.id}/initialinfo`)}
color: '#6366f1', style={{
textDecoration: 'none', color: '#6366f1',
fontSize: 13, textDecoration: 'none',
border: 'none', fontSize: 13,
background: 'rgba(99, 102, 241, 0.08)', border: 'none',
padding: '4px 12px', background: 'rgba(99, 102, 241, 0.08)',
borderRadius: 8, padding: '4px 12px',
cursor: 'pointer', borderRadius: 8,
transition: 'all 0.2s', cursor: 'pointer',
}} transition: 'all 0.2s',
> }}
>
</button>
</button>
<Popconfirm
title="确认删除这个爆款开头复刻任务吗?"
onConfirm={async () => {
try {
await deleteHotOpeningReplicationTask(record.id);
message.success('删除成功');
fetchList(1, pageSize, false, searchKeyword);
} catch (err) {
message.error('删除失败');
}
}}
>
<button
style={{
color: '#ef4444',
textDecoration: 'none',
fontSize: 13,
border: 'none',
background: 'rgba(239, 68, 68, 0.08)',
padding: '4px 12px',
borderRadius: 8,
cursor: 'pointer',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = 'rgba(239, 68, 68, 0.12)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = 'rgba(239, 68, 68, 0.08)';
}}
>
</button>
</Popconfirm>
</Space>
), ),
}, },
]} ]}
+312 -188
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; 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 { createRemoveLens, getShotReplicationDetail, Removelist, removeCreate, splitCustom, uploadImage } from '../api'; import { createRemoveLens, deleteSegment, getShotReplicationDetail, reanalyzeSegment, Removelist, removeCreate, reanalyzeShotReplication, splitCustom, uploadImage } from '../api';
import VideoTrimPicker from '../components/VideoTrimPicker'; import VideoTrimPicker from '../components/VideoTrimPicker';
const { TextArea } = Input; const { TextArea } = Input;
@@ -131,6 +131,51 @@ function RemoveInfo() {
} }
}, [creatID]); }, [creatID]);
const handleReanalyze = useCallback(async () => {
if (!creatID) return;
try {
await reanalyzeShotReplication(creatID);
message.success('重新分析已提交');
fetchTaskDetail();
if (!analysisPollingRef.current) {
analysisPollingRef.current = window.setInterval(async () => {
try {
const res = await getShotReplicationDetail(creatID);
setTaskDetail(res);
if (res.analysisStatus === 'completed' || res.analysisStatus === 'failed') {
if (analysisPollingRef.current) {
clearInterval(analysisPollingRef.current);
analysisPollingRef.current = null;
}
}
} catch { }
}, 3000);
}
} catch (error: any) {
message.error(error?.message || '重新分析失败');
}
}, [creatID, fetchTaskDetail]);
const handleSegmentReanalyze = useCallback(async (segmentId: string) => {
try {
await reanalyzeSegment(segmentId);
message.success('重新分析已提交');
fetchSegments();
} catch (error: any) {
message.error(error?.message || '重新分析失败');
}
}, [fetchSegments]);
const handleDeleteSegment = useCallback(async (segmentId: string) => {
try {
await deleteSegment(segmentId);
message.success('删除成功');
fetchSegments();
} catch (error: any) {
message.error(error?.message || '删除失败');
}
}, [fetchSegments]);
const refreshPageData = useCallback(async () => { const refreshPageData = useCallback(async () => {
await Promise.all([fetchTaskDetail(), fetchSegments()]); await Promise.all([fetchTaskDetail(), fetchSegments()]);
}, [fetchTaskDetail, fetchSegments]); }, [fetchTaskDetail, fetchSegments]);
@@ -154,7 +199,7 @@ function RemoveInfo() {
if (!taskDetail) return; if (!taskDetail) return;
if (taskDetail.analysisStatus === 'processing') { if (taskDetail.analysisStatus === 'processing') {
analysisPollingRef.current = window.setInterval(async () => { analysisPollingRef.current = window.setInterval(async () => {
try { try {
const res = await getShotReplicationDetail(creatID); const res = await getShotReplicationDetail(creatID);
@@ -227,13 +272,13 @@ function RemoveInfo() {
return; return;
} }
const params = { const params = {
target_project_name: productName.trim(), target_project_name: productName.trim(),
core_content_point: productSellingPoint.trim(), core_content_point: productSellingPoint.trim(),
material_image_url: buildAssetUrl(productImage), material_image_url: buildAssetUrl(productImage),
idempotency_key: `replication_${Date.now()}`, idempotency_key: `replication_${Date.now()}`,
}; };
setLoading(true); setLoading(true);
@@ -242,23 +287,23 @@ function RemoveInfo() {
message.success('视频生成任务创建成功'); message.success('视频生成任务创建成功');
handleCloseDrawer(); handleCloseDrawer();
Removelist(creatID).then((res: any) => { Removelist(creatID).then((res: any) => {
const targetItem = res.items.find((item: any) => item.id === currentSegment); const targetItem = res.items.find((item: any) => item.id === currentSegment);
message.loading('创建中...', 3); message.loading('创建中...', 3);
setTimeout(() => { setTimeout(() => {
navigate(`/removelens/${targetItem.moduleProjectId}/removefenbu`); navigate(`/removelens/${targetItem.moduleProjectId}/removefenbu`);
}, 3000); }, 3000);
}); });
// fetchSegments().then((res: any) => { // fetchSegments().then((res: any) => {
// console.log('123123123123',res); // console.log('123123123123',res);
// // const targetItem = res.items.find((item: any) => item.id === currentSegment); // // const targetItem = res.items.find((item: any) => item.id === currentSegment);
// // console.log(targetItem); // // console.log(targetItem);
// }); // });
// if (targetItem?.moduleProjectId) { // if (targetItem?.moduleProjectId) {
// message.loading('跳转中...', 1.5); // message.loading('跳转中...', 1.5);
// //
@@ -380,13 +425,13 @@ function RemoveInfo() {
width: 500, width: 500,
align: 'left' as const, align: 'left' as const,
render: (_: any, record: any) => { render: (_: any, record: any) => {
// AI建议的片段直接显示内容,不经过分析状态判断 // AI建议的片段直接显示内容,不经过分析状态判断
if (record.source_mode === 'ai_suggestion' || record.sourceMode === 'ai_suggestion') { if (record.source_mode === 'ai_suggestion' || record.sourceMode === 'ai_suggestion') {
return <div style={{ fontSize: 14, color: '#333', lineHeight: 1.6 }}>{record.segmentContent || record.lastError || '-'}</div>; return <div style={{ fontSize: 14, color: '#333', lineHeight: 1.6 }}>{record.segmentContent || record.lastError || '-'}</div>;
} }
const analysisStatus = record.analysis_status || record.analysisStatus; const analysisStatus = record.analysis_status || record.analysisStatus;
const statusMap: Record<string, string> = { const statusMap: Record<string, string> = {
'not_required': '无需单独分析', 'not_required': '无需单独分析',
@@ -394,7 +439,7 @@ function RemoveInfo() {
'processing': '分析中', 'processing': '分析中',
'failed': '分析失败', 'failed': '分析失败',
}; };
if (analysisStatus === 'processing') { if (analysisStatus === 'processing') {
return ( return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
@@ -403,11 +448,11 @@ function RemoveInfo() {
</div> </div>
); );
} }
if (analysisStatus === 'failed') { if (analysisStatus === 'failed') {
return <div style={{ fontSize: 14, color: '#ef4444', lineHeight: 1.6 }}></div>; return <div style={{ fontSize: 14, color: '#ef4444', lineHeight: 1.6 }}></div>;
} }
const displayText = analysisStatus && statusMap[analysisStatus] ? statusMap[analysisStatus] : (record.segmentContent || record.lastError || '-'); const displayText = analysisStatus && statusMap[analysisStatus] ? statusMap[analysisStatus] : (record.segmentContent || record.lastError || '-');
return <div style={{ fontSize: 14, color: '#333', lineHeight: 1.6 }}>{displayText}</div>; return <div style={{ fontSize: 14, color: '#333', lineHeight: 1.6 }}>{displayText}</div>;
}, },
@@ -427,100 +472,100 @@ function RemoveInfo() {
width: 120, width: 120,
align: 'center' as const, align: 'center' as const,
dataIndex: 'moduleProjectStatus', dataIndex: 'moduleProjectStatus',
render: (text: string, record: any) => { render: (text: string, record: any) => {
const status = text; const status = text;
const currentStepCode = record.moduleProjectCurrentStepCode || record.currentStepCode; const currentStepCode = record.moduleProjectCurrentStepCode || record.currentStepCode;
const getStatusText = () => { const getStatusText = () => {
if (currentStepCode === 'image_prompt_optimize') { if (currentStepCode === 'image_prompt_optimize') {
switch (status) { switch (status) {
case 'waiting_user': case 'waiting_user':
return { text: '等待融合图生成', color: '#f59e0b' }; return { text: '等待融合图生成', color: '#f59e0b' };
case 'processing': case 'processing':
return { text: '图片提示词生成中', color: '#f59e0b' }; return { text: '图片提示词生成中', color: '#f59e0b' };
case 'completed': case 'completed':
return { text: '图片提示词生成成功', color: '#10b981' }; return { text: '图片提示词生成成功', color: '#10b981' };
case 'failed': case 'failed':
return { text: '图片提示词生成失败', color: '#ef4444' }; return { text: '图片提示词生成失败', color: '#ef4444' };
default: default:
return { text: status || '-', color: '#666' }; return { text: status || '-', color: '#666' };
} }
} else if (currentStepCode === 'image_generate') { } else if (currentStepCode === 'image_generate') {
switch (status) { switch (status) {
case 'waiting_user': case 'waiting_user':
return { text: '等待生成视频提示词', color: '#f59e0b' }; return { text: '等待生成视频提示词', color: '#f59e0b' };
case 'processing': case 'processing':
return { text: '融合图生成中', color: '#f59e0b' }; return { text: '融合图生成中', color: '#f59e0b' };
case 'completed': case 'completed':
return { text: '融合图生成成功', color: '#10b981' }; return { text: '融合图生成成功', color: '#10b981' };
case 'failed': case 'failed':
return { text: '融合图生成失败', color: '#ef4444' }; return { text: '融合图生成失败', color: '#ef4444' };
default: default:
return status || '-'; return status || '-';
} }
} else if (currentStepCode === 'video_prompt_optimize') { } else if (currentStepCode === 'video_prompt_optimize') {
switch (status) { switch (status) {
case 'waiting_user': case 'waiting_user':
return { text: '等待最终视频生成', color: '#f59e0b' }; return { text: '等待最终视频生成', color: '#f59e0b' };
case 'processing': case 'processing':
return { text: '视频提示词生成中', color: '#f59e0b' }; return { text: '视频提示词生成中', color: '#f59e0b' };
case 'completed': case 'completed':
return { text: '视频提示词生成成功', color: '#10b981' }; return { text: '视频提示词生成成功', color: '#10b981' };
case 'failed': case 'failed':
return { text: '视频提示词生成失败', color: '#ef4444' }; return { text: '视频提示词生成失败', color: '#ef4444' };
default: default:
return { text: status || '-', color: '#666' }; return { text: status || '-', color: '#666' };
} }
} else if (currentStepCode === 'video_generate') { } else if (currentStepCode === 'video_generate') {
switch (status) { switch (status) {
case 'waiting_user': case 'waiting_user':
return { text: '', color: '#666' }; return { text: '', color: '#666' };
case 'processing': case 'processing':
return { text: '最终视频生成中', color: '#f59e0b' }; return { text: '最终视频生成中', color: '#f59e0b' };
case 'completed': case 'completed':
return { text: '任务完成', color: '#10b981' }; return { text: '任务完成', color: '#10b981' };
case 'failed': case 'failed':
return { text: '最终视频生成失败', color: '#ef4444' }; return { text: '最终视频生成失败', color: '#ef4444' };
default: default:
return { text: status || '-', color: '#666' }; return { text: status || '-', color: '#666' };
} }
} else if (currentStepCode === 'material_input') { } else if (currentStepCode === 'material_input') {
switch (status) { switch (status) {
case 'waiting_user': case 'waiting_user':
return { text: '等待生成图片提示词', color: '#f59e0b' }; return { text: '等待生成图片提示词', color: '#f59e0b' };
case 'processing': case 'processing':
return { text: '素材处理中', color: '#f59e0b' }; return { text: '素材处理中', color: '#f59e0b' };
case 'completed': case 'completed':
return { text: '素材上传成功', color: '#10b981' }; return { text: '素材上传成功', color: '#10b981' };
case 'failed': case 'failed':
return { text: '素材上传失败', color: '#ef4444' }; return { text: '素材上传失败', color: '#ef4444' };
default: default:
return { text: status || '-', color: '#666' }; return { text: status || '-', color: '#666' };
} }
} else { } else {
if (!record.moduleProjectId) { if (!record.moduleProjectId) {
return { text: '待生成任务', color: '#94a3b8' }; return { text: '待生成任务', color: '#94a3b8' };
} }
const statusMap: Record<string, { text: string; color: string }> = { const statusMap: Record<string, { text: string; color: string }> = {
'pending': { text: '子任务待处理', color: '#f59e0b' }, 'pending': { text: '子任务待处理', color: '#f59e0b' },
'waiting_user': { text: '等待用户确认或触发', color: '#f59e0b' }, 'waiting_user': { text: '等待用户确认或触发', color: '#f59e0b' },
'processing': { text: '子任务处理中', color: '#f59e0b' }, 'processing': { text: '子任务处理中', color: '#f59e0b' },
'completed': { text: '子任务完成', color: '#10b981' }, 'completed': { text: '子任务完成', color: '#10b981' },
'failed': { text: '子任务失败', color: '#ef4444' }, 'failed': { text: '子任务失败', color: '#ef4444' },
'cancelled': { text: '子任务取消', color: '#94a3b8' }, 'cancelled': { text: '子任务取消', color: '#94a3b8' },
}; };
const result = statusMap[status] || { text: status || '-', color: '#666' }; const result = statusMap[status] || { text: status || '-', color: '#666' };
return result; return result;
} }
}; };
const result = getStatusText() as { text: string; color: string }; const result = getStatusText() as { text: string; color: string };
if (typeof result === 'string') { if (typeof result === 'string') {
return <span style={{ fontSize: 12, color: '#666', fontWeight: 500 }}>{result}</span>; return <span style={{ fontSize: 12, color: '#666', fontWeight: 500 }}>{result}</span>;
} }
return <span style={{ fontSize: 12, color: result.color, fontWeight: 500 }}>{result.text}</span>; return <span style={{ fontSize: 12, color: result.color, fontWeight: 500 }}>{result.text}</span>;
}, },
// render: (_: any, record: any) => { // render: (_: any, record: any) => {
// const statusMap: Record<string, { text: string; color: string }> = { // const statusMap: Record<string, { text: string; color: string }> = {
@@ -559,6 +604,28 @@ function RemoveInfo() {
{canCreateReplication(record) ? '视频生成' : '待切割完成'} {canCreateReplication(record) ? '视频生成' : '待切割完成'}
</Button> </Button>
)} )}
{record.analysisStatus === 'failed' && (
<Button
type="text"
onClick={() => handleSegmentReanalyze(String(record.id))}
style={{ color: '#ef4444', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
>
</Button>
)}
<Popconfirm
title="确定删除此片段?"
onConfirm={() => handleDeleteSegment(String(record.id))}
okText="确定"
cancelText="取消"
>
<Button
type="text"
style={{ color: '#ef4444', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
>
</Button>
</Popconfirm>
</div> </div>
), ),
}, },
@@ -613,35 +680,88 @@ function RemoveInfo() {
</div> </div>
{taskDetail ? ( {taskDetail ? (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', overflow: 'hidden', padding: '0 32px 32px' }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', overflow: 'hidden', }}>
{/* 视频总结卡片 */} {/* 视频总结卡片 */}
<div <div
style={{ style={{
flex: 1, flex: 1,
background: 'linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(255,255,255,0.6) 100%)', background: 'linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(255,255,255,0.6) 100%)',
backdropFilter: 'blur(20px)', backdropFilter: 'blur(20px)',
borderRadius: 20, borderRadius: 20,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 30, gap: 30,
padding: 24, padding: 24,
border: '1px solid rgba(99, 102, 241, 0.1)', border: '1px solid rgba(99, 102, 241, 0.1)',
boxShadow: '0 8px 32px rgba(99, 102, 241, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.8)', // boxShadow: '0 8px 32px rgba(99, 102, 241, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.8)',
position: 'relative', position: 'relative',
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
{/* 卡片装饰 */}
<div style={{ position: 'absolute', top: -50, right: -50, width: 200, height: 200, background: 'radial-gradient(circle, rgba(99,102,241,0.05) 0%, transparent 70%)', borderRadius: '50%' }} /> <div>
<div
<div style={{ position: 'relative', width: 280, height: 160, borderRadius: 12, overflow: 'hidden', flexShrink: 0, boxShadow: '0 4px 16px rgba(0,0,0,0.1)' }}> style={{ position: 'relative', width: 280, height: 160, borderRadius: 12, overflow: 'hidden', flexShrink: 0, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', cursor: 'pointer' }}
<video onClick={() => {
controls setPreviewVideoUrl(videoUrl);
src={videoUrl} setPreviewModalVisible(true);
style={{ width: '100%', height: '100%'}} }}
/> onMouseEnter={(e) => {
const overlay = e.currentTarget.querySelector('div:last-child') as HTMLElement;
if (overlay) overlay.style.opacity = '1';
}}
onMouseLeave={(e) => {
const overlay = e.currentTarget.querySelector('div:last-child') as HTMLElement;
if (overlay) overlay.style.opacity = '0';
}}
>
<video
src={videoUrl}
style={{ width: '100%', height: '100%', objectFit: 'cover', }}
/>
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0, transition: 'opacity 0.2s' }}>
<div style={{ width: 40, height: 40, borderRadius: '50%', background: 'rgba(255,255,255,0.9)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#6366f1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="5 3 19 12 5 21 5 3"></polygon>
</svg>
</div>
</div>
</div>
{taskDetail.analysisStatus === 'failed' && (
<div style={{ marginTop: 12, textAlign: 'center' }}>
<Button
onClick={handleReanalyze}
style={{
width: "100%",
padding: '6px 16px',
borderRadius: 8,
fontSize: 12,
border: '1px solid #ef4444',
color: '#ef4444',
background: 'rgba(239, 68, 68, 0.05)',
cursor: taskDetail.analysisStatus === 'processing' ? 'not-allowed' : 'pointer',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
if (taskDetail.analysisStatus !== 'processing') {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.05)';
}}
>
{taskDetail.analysisStatus === 'processing' ? '分析中...' : '重新分析'}
</Button>
</div>
)}
</div> </div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', position: 'relative', zIndex: 1 }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', position: 'relative', zIndex: 1 }}>
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -669,6 +789,8 @@ function RemoveInfo() {
</Tag> </Tag>
))} ))}
</div> </div>
) : taskDetail.analysisStatus === 'failed' ? (
<span style={{ color: '#ef4444', fontSize: 14 }}></span>
) : null} ) : null}
</div> </div>
<div style={{ display: 'flex' }}> <div style={{ display: 'flex' }}>
@@ -679,7 +801,9 @@ function RemoveInfo() {
<span style={{ fontSize: 14, color: '#f59e0b' }}></span> <span style={{ fontSize: 14, color: '#f59e0b' }}></span>
</div> </div>
) : taskDetail.analysisStatus === 'completed' ? ( ) : taskDetail.analysisStatus === 'completed' ? (
<span style={{ color: '#475569', lineHeight: 1.6 , height: 100,overflowY: 'auto'}}>{taskDetail.originalVideoContent}</span> <span style={{ color: '#475569', lineHeight: 1.6, height: 100, overflowY: 'auto' }}>{taskDetail.originalVideoContent}</span>
) : taskDetail.analysisStatus === 'failed' ? (
<span style={{ color: '#ef4444', fontSize: 14 }}></span>
) : null} ) : null}
</div> </div>
</div> </div>
@@ -748,55 +872,55 @@ function RemoveInfo() {
> >
<span style={{ position: 'relative', zIndex: 1 }}>{autoSplitButtonText}</span> <span style={{ position: 'relative', zIndex: 1 }}>{autoSplitButtonText}</span>
{/* 按钮光效 */} {/* 按钮光效 */}
<div style={{ <div style={{
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: '-100%', left: '-100%',
width: '100%', width: '100%',
height: '100%', height: '100%',
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent)', background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent)',
transition: 'left 0.5s ease' transition: 'left 0.5s ease'
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.left = '100%'; e.currentTarget.style.left = '100%';
}} /> }} />
{taskDetail?.aiSuggestions && taskDetail.aiSuggestions.length > 0 && ( {taskDetail?.aiSuggestions && taskDetail.aiSuggestions.length > 0 && (
<Tooltip <Tooltip
title={ title={
<div> <div>
<div style={{ fontWeight: 600, marginBottom: 8, color: '#6366f1' }}>AI拆分方案</div> <div style={{ fontWeight: 600, marginBottom: 8, color: '#6366f1' }}>AI拆分方案</div>
{(() => { {(() => {
const suggestions = taskDetail?.ai_suggestions || taskDetail?.aiSuggestions || []; const suggestions = taskDetail?.ai_suggestions || taskDetail?.aiSuggestions || [];
return suggestions.map((item: any) => ( return suggestions.map((item: any) => (
<div key={item.index} style={{ marginBottom: 8, fontSize: 13, lineHeight: 1.5 }}> <div key={item.index} style={{ marginBottom: 8, fontSize: 13, lineHeight: 1.5 }}>
<span style={{ fontWeight: 500, color: '#6366f1' }}>{item.index}.</span> <span style={{ fontWeight: 500, color: '#6366f1' }}>{item.index}.</span>
<span style={{ marginLeft: 4, color: '#000000ff' }}>{item.timeNode}</span> <span style={{ marginLeft: 4, color: '#000000ff' }}>{item.timeNode}</span>
<br /> <br />
<span style={{ color: '#666' }}>{item.content}</span> <span style={{ color: '#666' }}>{item.content}</span>
</div> </div>
)); ));
})()} })()}
</div> </div>
} }
placement="top" placement="top"
trigger="hover" trigger="hover"
overlayInnerStyle={{ backgroundColor: '#fff', height: 300, overflowY: 'auto', border: '1px solid rgba(99, 102, 241, 0.1)', borderRadius: 12, boxShadow: '0 8px 24px rgba(99, 102, 241, 0.12)' }} overlayInnerStyle={{ backgroundColor: '#fff', height: 300, overflowY: 'auto', border: '1px solid rgba(99, 102, 241, 0.1)', borderRadius: 12, boxShadow: '0 8px 24px rgba(99, 102, 241, 0.12)' }}
> >
<span style={{ marginLeft: 8, fontSize: 13, opacity: 0.9 }}>(AI )</span> <span style={{ marginLeft: 8, fontSize: 13, opacity: 0.9 }}>(AI )</span>
</Tooltip> </Tooltip>
)} )}
</Button> </Button>
</Popconfirm> </Popconfirm>
</div> </div>
<div <div
style={{ style={{
flex: 2, flex: 2,
background: 'linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 100%)', background: 'linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 100%)',
backdropFilter: 'blur(20px)', backdropFilter: 'blur(20px)',
borderRadius: 20, borderRadius: 20,
overflow: 'hidden', overflow: 'hidden',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
border: '1px solid rgba(99, 102, 241, 0.1)', border: '1px solid rgba(99, 102, 241, 0.1)',
boxShadow: '0 8px 32px rgba(99, 102, 241, 0.08)', boxShadow: '0 8px 32px rgba(99, 102, 241, 0.08)',
@@ -817,43 +941,43 @@ function RemoveInfo() {
components={{ components={{
body: { body: {
row: ({ className, style, ...rest }) => ( row: ({ className, style, ...rest }) => (
<tr <tr
{...rest} {...rest}
className={className} className={className}
style={{ style={{
...style, ...style,
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
borderBottom: '1px solid rgba(99, 102, 241, 0.05)', borderBottom: '1px solid rgba(99, 102, 241, 0.05)',
height: 80, height: 80,
overflow: 'auto', overflow: 'auto',
}} }}
/> />
), ),
cell: ({ className, style, ...rest }) => ( cell: ({ className, style, ...rest }) => (
<td <td
{...rest} {...rest}
className={className} className={className}
style={{ style={{
...style, ...style,
padding: '16px 24px', padding: '16px 24px',
}} }}
/> />
), ),
}, },
header: { header: {
cell: ({ className, style, ...rest }) => ( cell: ({ className, style, ...rest }) => (
<th <th
{...rest} {...rest}
className={className} className={className}
style={{ style={{
...style, ...style,
background: 'rgba(255, 255, 255, 1)', background: 'rgba(255, 255, 255, 1)',
color: '#64748b', color: '#64748b',
fontWeight: 500, fontWeight: 500,
fontSize: 13, fontSize: 13,
padding: '16px 24px', padding: '16px 24px',
borderBottom: 'none', borderBottom: 'none',
}} }}
/> />
), ),
}, },
@@ -1011,17 +1135,17 @@ function RemoveInfo() {
centered centered
styles={{ styles={{
body: { padding: 24, }, body: { padding: 24, },
header: { borderBottom: '1px solid #e0e0e0', padding: '20px 24px' }, header: { borderBottom: '1px solid #e0e0e0', padding: '20px 24px' },
}} }}
> >
<div style={{ borderRadius: 12, overflow: 'hidden', }}> <div style={{ borderRadius: 12, overflow: 'hidden', }}>
<video <video
ref={previewVideoRef} ref={previewVideoRef}
controls controls
src={previewVideoUrl || ''} src={previewVideoUrl || ''}
style={{ width: '100%', maxHeight: 420, objectFit: 'contain', backgroundColor: '#000' }} style={{ width: '100%', maxHeight: 420, objectFit: 'contain', backgroundColor: '#000' }}
playsInline playsInline
webkit-playsinline webkit-playsinline="true"
autoPlay autoPlay
/> />
</div> </div>
+78 -21
View File
@@ -1,10 +1,27 @@
import { useState, useRef, useCallback } from 'react'; import { useState, useRef, useCallback } from 'react';
import { Button, Modal, Input, Table, Upload, Popconfirm, message } from 'antd'; import { Button, Modal, Input, Table, Upload, Popconfirm, Tag, Space, message } from 'antd';
import { FileTextOutlined, CloudUploadOutlined } from '@ant-design/icons'; import { FileTextOutlined, CloudUploadOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { uploadVideo, createShotReplication, getShotReplicationList } from '../api'; import { uploadVideo, createShotReplication, getShotReplicationList, deleteShotReplicationProject } from '../api';
import bg1 from '../assets/bg1.png'; import bg1 from '../assets/bg1.png';
const statusConfig: Record<string, { label: string; color: string }> = {
pending_analysis: { label: '等待分析', color: 'default' },
analyzing: { label: '分析中', color: 'processing' },
analysis_completed: { label: '分析完成', color: 'blue' },
analysis_failed: { label: '分析失败', color: 'red' },
splitting: { label: '拆镜中', color: 'processing' },
split_completed: { label: '拆镜完成', color: 'green' },
partial_failed: { label: '部分失败', color: 'orange' },
failed: { label: '失败', color: 'red' },
deleted: { label: '已软删', color: 'default' },
};
const renderStatus = (status: string) => {
const config = statusConfig[status] || { label: status, color: 'default' };
return <Tag color={config.color}>{config.label}</Tag>;
};
export default function VideoFrameExtractor() { export default function VideoFrameExtractor() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -542,6 +559,12 @@ export default function VideoFrameExtractor() {
<span style={{ fontSize: 14, color: '#1e293b', fontWeight: 500 }}>{text}</span> <span style={{ fontSize: 14, color: '#1e293b', fontWeight: 500 }}>{text}</span>
), ),
}, },
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => renderStatus(status),
},
{ {
title: '创建时间', title: '创建时间',
dataIndex: 'createdAt', dataIndex: 'createdAt',
@@ -563,25 +586,59 @@ export default function VideoFrameExtractor() {
key: 'action', key: 'action',
align: 'center', align: 'center',
render: (record) => ( render: (record) => (
<Button <Space>
type="text" <Button
onClick={() => navigate(`/removelens/${record.id}/removeinfo`)} type="text"
style={{ onClick={() => navigate(`/removelens/${record.id}/removeinfo`)}
color: '#6366f1', style={{
fontSize: 13, color: '#6366f1',
padding: '4px 12px', fontSize: 13,
borderRadius: 6, padding: '4px 12px',
background: 'rgba(99, 102, 241, 0.1)', borderRadius: 6,
}} background: 'rgba(99, 102, 241, 0.1)',
onMouseEnter={(e) => { }}
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.15)'; onMouseEnter={(e) => {
}} e.currentTarget.style.background = 'rgba(99, 102, 241, 0.15)';
onMouseLeave={(e) => { }}
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.1)'; onMouseLeave={(e) => {
}} e.currentTarget.style.background = 'rgba(99, 102, 241, 0.1)';
> }}
>
</Button>
</Button>
<Popconfirm
title="确认删除这个拆镜项目吗?"
onConfirm={async () => {
try {
await deleteShotReplicationProject(record.id);
message.success('删除成功');
fetchList(currentPage, pageSize, searchKeyword);
} catch (err) {
message.error('删除失败');
}
}}
>
<Button
type="text"
danger
style={{
color: '#ef4444',
fontSize: 13,
padding: '4px 12px',
borderRadius: 6,
background: 'rgba(239, 68, 68, 0.1)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
}}
>
</Button>
</Popconfirm>
</Space>
), ),
}, },
]} ]}