“真人人像/虚拟素材批量上传”

This commit is contained in:
sjy
2026-07-13 17:54:01 +08:00
parent dc2e527977
commit 5b937f652b
10 changed files with 995 additions and 635 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-CEhV1cDD.js"></script>
<script type="module" crossorigin src="/assets/index-BoVQkhEX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-JhRVnnL-.css">
</head>
<body>
@@ -1,8 +1,9 @@
import React, { useState } from 'react';
import { Button, Input, Modal, Space, Upload, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import { Button, Input, Modal, Space, Upload, Spin, message } from 'antd';
import { UploadOutlined, DeleteOutlined, PictureOutlined, VideoCameraOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import { createPrivatePortraitAsset, uploadPrivatePortraitImage, uploadPrivatePortraitVideo } from '../../../api';
import { useLibrary } from './LibraryContext';
const MIN_PRIVATE_VIDEO_DURATION = 2;
const MAX_PRIVATE_VIDEO_DURATION = 15;
@@ -61,50 +62,61 @@ const validatePrivateVideoDuration = (duration: number | null): duration is numb
};
const PrivatePortraitAssetUpload: React.FC<Props> = ({ projectId, open, onClose, onSuccess }) => {
const { refreshConfig } = useLibrary();
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [name, setName] = useState('');
const [assetNames, setAssetNames] = useState<Record<string, string>>({});
const [uploadStatus, setUploadStatus] = useState<Record<string, 'uploading' | 'uploaded' | 'error'>>({});
const [uploadResults, setUploadResults] = useState<Record<string, { url: string; duration_seconds?: number; file_size_bytes?: number; resource_id?: string }>>({});
const [importStatus, setImportStatus] = useState<Record<string, 'importing' | 'imported' | 'error'>>({});
const [loading, setLoading] = useState(false);
const reset = () => {
setFileList([]);
setName('');
setAssetNames({});
setUploadStatus({});
setUploadResults({});
setImportStatus({});
};
const handleSubmit = async () => {
const file = fileList[0]?.originFileObj as File | undefined;
if (!file) {
message.warning('请先选择图片或视频素材');
return;
}
const assetType = guessAssetType(file);
if (assetType === 'Image' && !file.type.startsWith('image/')) {
message.error('仅支持图片或视频素材');
const uploadedFiles = fileList.filter((f) => uploadStatus[f.uid] === 'uploaded');
if (uploadedFiles.length === 0) {
message.warning('请等待文件上传完成后再提交入库');
return;
}
setLoading(true);
try {
const videoDuration = assetType === 'Video' ? await getVideoDuration(file) : null;
if (assetType === 'Video' && !validatePrivateVideoDuration(videoDuration)) {
return;
}
for (const fileItem of uploadedFiles) {
const file = fileItem.originFileObj as File | undefined;
if (!file) continue;
const uploadResult = uploadResults[fileItem.uid];
if (!uploadResult) continue;
const uploaded = assetType === 'Video' ? await uploadPrivatePortraitVideo(file, videoDuration || undefined) : await uploadPrivatePortraitImage(file);
setImportStatus((prev) => ({ ...prev, [fileItem.uid]: 'importing' }));
const currentType = file.type.startsWith('image/') ? 'Image' : 'Video';
await createPrivatePortraitAsset(projectId, {
url: uploaded.url,
assetType,
name: name.trim() || file.name,
videoDuration: uploaded.duration_seconds ?? videoDuration,
fileSize: uploaded.file_size_bytes ?? file.size,
url: uploadResult.url,
assetType: currentType,
name: assetNames[fileItem.uid]?.trim() || file.name,
videoDuration: uploadResult.duration_seconds,
fileSize: uploadResult.file_size_bytes ?? file.size,
mimeType: file.type || null,
uploadResourceId: uploaded.resource_id || null,
uploadResourceId: uploadResult.resource_id || null,
});
message.success(assetType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中');
setImportStatus((prev) => ({ ...prev, [fileItem.uid]: 'imported' }));
}
message.success(`已成功提交 ${uploadedFiles.length} 个素材入库,处理中`);
void refreshConfig();
setTimeout(() => {
reset();
onSuccess();
onClose();
}, 1500);
} catch (e: any) {
message.error(e?.message || '上传素材失败');
message.error(e?.message || '提交入库失败');
} finally {
setLoading(false);
}
@@ -114,25 +126,170 @@ const PrivatePortraitAssetUpload: React.FC<Props> = ({ projectId, open, onClose,
<Modal
title="上传真人素材"
open={open}
onCancel={onClose}
onCancel={() => {
reset();
onClose();
}}
onOk={handleSubmit}
confirmLoading={loading}
okText="提交入库"
destroyOnHidden
width={650}
okButtonProps={{
disabled: fileList.length === 0 || fileList.some((f) => uploadStatus[f.uid] !== 'uploaded'),
}}
>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="素材名称,默认使用文件名" maxLength={256} />
<Space direction="vertical" style={{ width: '100%' }} size={14}>
<Upload
accept="image/*,video/*"
maxCount={1}
fileList={fileList}
beforeUpload={() => false}
onChange={({ fileList }) => setFileList(fileList)}
multiple
beforeUpload={async (file) => {
const uid = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const isImage = file.type.startsWith('image/');
const previewUrl = URL.createObjectURL(file);
const newFile: UploadFile = {
uid,
name: file.name,
status: 'uploading',
originFileObj: file,
thumbUrl: previewUrl,
type: file.type,
};
setFileList((prev) => [...prev, newFile]);
setAssetNames((prev) => ({ ...prev, [uid]: '' }));
setUploadStatus((prev) => ({ ...prev, [uid]: 'uploading' }));
try {
const currentType = isImage ? 'Image' : 'Video';
const duration = currentType === 'Video' ? await getVideoDuration(file) : null;
if (currentType === 'Video' && !validatePrivateVideoDuration(duration)) {
setUploadStatus((prev) => ({ ...prev, [uid]: 'error' }));
return false;
}
const uploaded = currentType === 'Video' ? await uploadPrivatePortraitVideo(file, duration || undefined) : await uploadPrivatePortraitImage(file);
setUploadResults((prev) => ({
...prev,
[uid]: {
url: uploaded.url,
duration_seconds: uploaded.duration_seconds,
file_size_bytes: uploaded.file_size_bytes,
resource_id: uploaded.resource_id,
},
}));
setUploadStatus((prev) => ({ ...prev, [uid]: 'uploaded' }));
} catch {
setUploadStatus((prev) => ({ ...prev, [uid]: 'error' }));
message.error(`文件 ${file.name} 上传失败`);
}
return false;
}}
listType="picture"
itemRender={(_, file, actions) => {
const isVideo = file.type?.startsWith('video/') || file.name.match(/\.(mp4|mov|avi|webm)$/i);
const uploadState = uploadStatus[file.uid];
const importState = importStatus[file.uid];
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 12, border: '1px solid #e5e7eb', borderRadius: 8, marginBottom: 8 }}>
<div style={{ width: 64, height: 64, borderRadius: 8, overflow: 'hidden', flexShrink: 0 }}>
{file.thumbUrl || file.url ? (
isVideo ? (
<video src={file.thumbUrl || file.url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<img src={file.thumbUrl || file.url} alt={file.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
)
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#f1f5f9' }}>
{isVideo ? (
<VideoCameraOutlined style={{ color: '#94a3b8', fontSize: 20 }} />
) : (
<PictureOutlined style={{ color: '#94a3b8', fontSize: 20 }} />
)}
</div>
)}
</div>
<span style={{ width: '20%', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', fontSize: 13, color: '#64748b', flexShrink: 0 }}>{file.name}</span>
<Input
style={{ width: 200, flexShrink: 0 }}
value={assetNames[file.uid] || ''}
onChange={(e) => setAssetNames({ ...assetNames, [file.uid]: e.target.value })}
placeholder="素材名称,默认使用文件名"
maxLength={256}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{uploadState === 'uploading' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Spin size="small" style={{ color: '#6366f1' }} />
<span style={{ fontSize: 12, color: '#6366f1' }}></span>
</div>
)}
{uploadState === 'uploaded' && !importState && (
<span style={{ fontSize: 12, color: '#10b981' }}> </span>
)}
{uploadState === 'error' && (
<span style={{ fontSize: 12, color: '#ef4444' }}> </span>
)}
{importState === 'importing' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Spin size="small" style={{ color: '#6366f1' }} />
<span style={{ fontSize: 12, color: '#6366f1' }}></span>
</div>
)}
{importState === 'imported' && (
<span style={{ fontSize: 12, color: '#10b981' }}> </span>
)}
{importState === 'error' && (
<span style={{ fontSize: 12, color: '#ef4444' }}> </span>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
setFileList((prev) => prev.filter((f) => f.uid !== file.uid));
setAssetNames((prev) => {
const next = { ...prev };
delete next[file.uid];
return next;
});
setUploadStatus((prev) => {
const next = { ...prev };
delete next[file.uid];
return next;
});
setUploadResults((prev) => {
const next = { ...prev };
delete next[file.uid];
return next;
});
setImportStatus((prev) => {
const next = { ...prev };
delete next[file.uid];
return next;
});
}}
style={{
width: 28,
height: 28,
borderRadius: '50%',
backgroundColor: '#f1f5f9',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<DeleteOutlined style={{ color: '#64748b', fontSize: 14 }} />
</button>
</div>
);
}}
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<div style={{ color: '#64748b', fontSize: 12 }}>
<div style={{ padding: 12, background: '#f8fafc', borderRadius: 12, color: '#64748b', fontSize: 13 }}>
{MIN_PRIVATE_VIDEO_DURATION}~{MAX_PRIVATE_VIDEO_DURATION} Active AI
</div>
</Space>
@@ -0,0 +1,20 @@
import React, { createContext, useContext } from 'react';
import type { ReactNode } from 'react';
interface LibraryContextType {
refreshConfig: () => void;
}
const LibraryContext = createContext<LibraryContextType | undefined>(undefined);
export const LibraryProvider: React.FC<{ children: ReactNode; refreshConfig: () => void }> = ({ children, refreshConfig }) => {
return <LibraryContext.Provider value={{ refreshConfig }}>{children}</LibraryContext.Provider>;
};
export const useLibrary = () => {
const context = useContext(LibraryContext);
if (!context) {
throw new Error('useLibrary must be used within a LibraryProvider');
}
return context;
};
@@ -5,6 +5,7 @@ import { getPrivatePortraitConfig, getPrivatePortraitProjects, getPrivatePortrai
import type { PrivatePortraitConfig, PrivatePortraitProject } from '../../../types';
import RealPersonLibraryPanel from './RealPersonLibraryPanel';
import VirtualMaterialPanel from './VirtualMaterialPanel';
import { LibraryProvider } from './LibraryContext';
const { Title, Text, Paragraph } = Typography;
@@ -21,6 +22,20 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string>();
const loadConfig = async () => {
try {
if (activeKey === 'aigc_virtual') {
const configRes = await getPrivatePortraitVirtualConfig();
setConfig(configRes);
} else {
const configRes = await getPrivatePortraitConfig();
setConfig(configRes);
}
} catch (err: any) {
message.error(err?.message || '加载配置失败');
}
};
useEffect(() => {
const loadData = async () => {
try {
@@ -84,6 +99,7 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
};
return (
<LibraryProvider refreshConfig={loadConfig}>
<div>
{/* <div style={{ marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}>私域素材库</Title>
@@ -122,10 +138,11 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
items={items}
destroyOnHidden={false}
tabBarExtraContent={
<div style={{ fontSize: 14, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}//</div>
<div style={{ fontSize: 14, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}//</div>
}
/>
</div>
</LibraryProvider>
);
};
@@ -5,6 +5,7 @@ import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../type
import { deletePrivatePortraitAsset, deletePrivatePortraitProject, getPrivatePortraitAssets } from '../../../api';
import PrivatePortraitAssetGrid from './AssetGrid';
import PrivatePortraitAssetUpload from './AssetUpload';
import { useLibrary } from './LibraryContext';
interface Props {
project: PrivatePortraitProject;
@@ -13,6 +14,7 @@ interface Props {
}
const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onChanged }) => {
const { refreshConfig } = useLibrary();
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
const [loading, setLoading] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
@@ -51,6 +53,7 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
await deletePrivatePortraitAsset(assetId);
await loadAssets(assetPage, assetPageSize);
onChanged();
void refreshConfig();
message.success('素材已删除');
} catch (e: any) {
message.error(e?.message || '删除素材失败');
@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useLibrary } from './LibraryContext';
import {
App,
Button,
@@ -131,6 +132,7 @@ const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
const VirtualMaterialPanel: React.FC = () => {
const { message } = App.useApp();
const { refreshConfig } = useLibrary();
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string>();
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
@@ -147,7 +149,10 @@ const VirtualMaterialPanel: React.FC = () => {
const [uploadOpen, setUploadOpen] = useState(false);
const [uploading, setUploading] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [assetName, setAssetName] = useState('');
const [assetNames, setAssetNames] = useState<Record<string, string>>({});
const [uploadStatus, setUploadStatus] = useState<Record<string, 'uploading' | 'uploaded' | 'error'>>({});
const [uploadResults, setUploadResults] = useState<Record<string, { url: string; duration_seconds?: number; file_size_bytes?: number; resource_id?: string }>>({});
const [importStatus, setImportStatus] = useState<Record<string, 'importing' | 'imported' | 'error'>>({});
const [previewOpen, setPreviewOpen] = useState(false);
const [previewUrl, setPreviewUrl] = useState('');
const [previewType, setPreviewType] = useState<'Image' | 'Video'>('Image');
@@ -259,40 +264,52 @@ const VirtualMaterialPanel: React.FC = () => {
message.warning('请先创建或选择项目组');
return;
}
const file = fileList[0]?.originFileObj as File | undefined;
if (!file) {
if (fileList.length === 0) {
message.warning('请先选择图片或视频素材');
return;
}
const currentType = guessAssetType(file);
if (currentType === 'Image' && !file.type.startsWith('image/')) {
message.error('仅支持图片或视频素材');
const uploadedFiles = fileList.filter((f) => uploadStatus[f.uid] === 'uploaded');
if (uploadedFiles.length === 0) {
message.warning('请等待文件上传完成后再提交入库');
return;
}
setUploading(true);
try {
const duration = currentType === 'Video' ? await getVideoDuration(file) : null;
if (currentType === 'Video' && !validatePrivateVideoDuration(duration, message.error)) {
return;
}
const uploaded = currentType === 'Video' ? await uploadPrivatePortraitVirtualVideo(file, duration || undefined) : await uploadPrivatePortraitVirtualImage(file);
for (const fileItem of uploadedFiles) {
const file = fileItem.originFileObj as File | undefined;
if (!file) continue;
const uploadResult = uploadResults[fileItem.uid];
if (!uploadResult) continue;
setImportStatus((prev) => ({ ...prev, [fileItem.uid]: 'importing' }));
const currentType = file.type.startsWith('image/') ? 'Image' : 'Video';
await createPrivatePortraitVirtualAsset(selectedProjectId, {
url: uploaded.url,
url: uploadResult.url,
assetType: currentType,
name: assetName.trim() || file.name,
videoDuration: uploaded.duration_seconds ?? duration,
fileSize: uploaded.file_size_bytes ?? file.size,
name: assetNames[fileItem.uid]?.trim() || file.name,
videoDuration: uploadResult.duration_seconds,
fileSize: uploadResult.file_size_bytes ?? file.size,
mimeType: file.type || null,
uploadResourceId: uploaded.resource_id || null,
uploadResourceId: uploadResult.resource_id || null,
});
message.success(currentType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中');
setImportStatus((prev) => ({ ...prev, [fileItem.uid]: 'imported' }));
}
message.success(`已成功提交 ${uploadedFiles.length} 个素材入库,处理中`);
setTimeout(() => {
setUploadOpen(false);
setFileList([]);
setAssetName('');
await Promise.all([loadProjects(), loadAssets(1, assetPageSize)]);
setAssetNames({});
setUploadStatus({});
setUploadResults({});
setImportStatus({});
}, 1500);
await Promise.all([loadProjects(), loadAssets(1, assetPageSize), refreshConfig()]);
} catch (err: any) {
message.error(err?.message || '上传素材失败');
message.error(err?.message || '提交入库失败');
} finally {
setUploading(false);
}
@@ -302,7 +319,7 @@ const VirtualMaterialPanel: React.FC = () => {
try {
await deletePrivatePortraitVirtualAsset(assetId);
message.success('素材已删除,远端删除将异步执行');
await Promise.all([loadProjects(), loadAssets(assetPage, assetPageSize)]);
await Promise.all([loadProjects(), loadAssets(assetPage, assetPageSize), refreshConfig()]);
} catch (err: any) {
message.error(err?.message || '删除素材失败');
}
@@ -571,21 +588,163 @@ const VirtualMaterialPanel: React.FC = () => {
onOk={handleUpload}
confirmLoading={uploading}
okText="提交入库"
width={650}
okButtonProps={{
disabled: fileList.length === 0 || fileList.some((f) => uploadStatus[f.uid] !== 'uploaded'),
}}
>
<Space direction="vertical" style={{ width: '100%' }} size={14}>
<Input value={assetName} onChange={(e) => setAssetName(e.target.value)} placeholder="素材名称,默认使用文件名" maxLength={256} />
<Upload
accept="image/*,video/*"
maxCount={1}
fileList={fileList}
beforeUpload={() => false}
onChange={({ fileList: next }) => setFileList(next)}
multiple
beforeUpload={async (file) => {
const uid = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const isImage = file.type.startsWith('image/');
const previewUrl = URL.createObjectURL(file);
const newFile: UploadFile = {
uid,
name: file.name,
status: 'uploading',
originFileObj: file,
thumbUrl: previewUrl,
type: file.type,
};
setFileList((prev) => [...prev, newFile]);
setAssetNames((prev) => ({ ...prev, [uid]: '' }));
setUploadStatus((prev) => ({ ...prev, [uid]: 'uploading' }));
try {
const currentType = isImage ? 'Image' : 'Video';
const duration = currentType === 'Video' ? await getVideoDuration(file) : null;
if (currentType === 'Video' && !validatePrivateVideoDuration(duration, message.error)) {
setUploadStatus((prev) => ({ ...prev, [uid]: 'error' }));
return false;
}
const uploaded = currentType === 'Video' ? await uploadPrivatePortraitVirtualVideo(file, duration || undefined) : await uploadPrivatePortraitVirtualImage(file);
setUploadResults((prev) => ({
...prev,
[uid]: {
url: uploaded.url,
duration_seconds: uploaded.duration_seconds,
file_size_bytes: uploaded.file_size_bytes,
resource_id: uploaded.resource_id,
},
}));
setUploadStatus((prev) => ({ ...prev, [uid]: 'uploaded' }));
} catch {
setUploadStatus((prev) => ({ ...prev, [uid]: 'error' }));
message.error(`文件 ${file.name} 上传失败`);
}
return false;
}}
listType="picture"
itemRender={(_, file, actions) => {
const isVideo = file.type?.startsWith('video/') || file.name.match(/\.(mp4|mov|avi|webm)$/i);
const uploadState = uploadStatus[file.uid];
const importState = importStatus[file.uid];
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 12, border: '1px solid #e5e7eb', borderRadius: 8, marginBottom: 8 }}>
<div style={{ width: 64, height: 64, borderRadius: 8, overflow: 'hidden', flexShrink: 0 }}>
{file.thumbUrl || file.url ? (
isVideo ? (
<video src={file.thumbUrl || file.url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<img src={file.thumbUrl || file.url} alt={file.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
)
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#f1f5f9' }}>
{isVideo ? (
<VideoCameraOutlined style={{ color: '#94a3b8', fontSize: 20 }} />
) : (
<PictureOutlined style={{ color: '#94a3b8', fontSize: 20 }} />
)}
</div>
)}
</div>
<span style={{ width: '20%', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', fontSize: 13, color: '#64748b', flexShrink: 0 }}>{file.name}</span>
<Input
style={{ width: 200, flexShrink: 0 }}
value={assetNames[file.uid] || ''}
onChange={(e) => setAssetNames({ ...assetNames, [file.uid]: e.target.value })}
placeholder="素材名称,默认使用文件名"
maxLength={256}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{uploadState === 'uploading' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Spin size="small" style={{ color: '#6366f1' }} />
<span style={{ fontSize: 12, color: '#6366f1' }}></span>
</div>
)}
{uploadState === 'uploaded' && !importState && (
<span style={{ fontSize: 12, color: '#10b981' }}> </span>
)}
{uploadState === 'error' && (
<span style={{ fontSize: 12, color: '#ef4444' }}> </span>
)}
{importState === 'importing' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Spin size="small" style={{ color: '#6366f1' }} />
<span style={{ fontSize: 12, color: '#6366f1' }}></span>
</div>
)}
{importState === 'imported' && (
<span style={{ fontSize: 12, color: '#10b981' }}> </span>
)}
{importState === 'error' && (
<span style={{ fontSize: 12, color: '#ef4444' }}> </span>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
setFileList((prev) => prev.filter((f) => f.uid !== file.uid));
setAssetNames((prev) => {
const next = { ...prev };
delete next[file.uid];
return next;
});
setUploadStatus((prev) => {
const next = { ...prev };
delete next[file.uid];
return next;
});
setUploadResults((prev) => {
const next = { ...prev };
delete next[file.uid];
return next;
});
setImportStatus((prev) => {
const next = { ...prev };
delete next[file.uid];
return next;
});
}}
style={{
width: 28,
height: 28,
borderRadius: '50%',
backgroundColor: '#f1f5f9',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<DeleteOutlined style={{ color: '#64748b', fontSize: 14 }} />
</button>
</div>
);
}}
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<div style={{ padding: 12, background: '#f8fafc', borderRadius: 12, color: '#64748b', fontSize: 13 }}>
{MIN_PRIVATE_VIDEO_DURATION}~{MAX_PRIVATE_VIDEO_DURATION} CreateAsset Active AI
{MIN_PRIVATE_VIDEO_DURATION}~{MAX_PRIVATE_VIDEO_DURATION} CreateAsset Active AI
</div>
</Space>
</Modal>
+6 -2
View File
@@ -2786,7 +2786,7 @@ const AIChatPage: React.FC = () => {
{firstFrame ? (
<div style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64 }}>
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${firstFrame.url}`}
src={buildPreviewUrl(firstFrame.url)}
alt={firstFrame.name}
onClick={() => {
setAttachmentPreviewUrl(firstFrame.url);
@@ -2836,6 +2836,8 @@ const AIChatPage: React.FC = () => {
url: previewUrl,
role: 'first_frame',
label: '',
source: 'private_portrait_asset',
private_asset_id: asset.id,
};
setFirstFrame(mediaRef);
antdMessage.success('成功添加首帧');
@@ -2875,7 +2877,7 @@ const AIChatPage: React.FC = () => {
{lastFrame ? (
<div style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64 }}>
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${lastFrame.url}`}
src={buildPreviewUrl(lastFrame.url)}
alt={lastFrame.name}
onClick={() => {
setAttachmentPreviewUrl(lastFrame.url);
@@ -2926,6 +2928,8 @@ const AIChatPage: React.FC = () => {
url: previewUrl,
role: 'last_frame',
label: '',
source: 'private_portrait_asset',
private_asset_id: asset.id,
};
setLastFrame(mediaRef);
antdMessage.success('成功添加尾帧');
+1 -1
View File
@@ -349,7 +349,7 @@ const HomePage: React.FC = () => {
},
{
icon: <RobotOutlined style={{ fontSize: 24 }} />,
title: 'AI成片',
title: 'AI创作',
description: '输入想法、剧本或上传参考,智能生成视频/图片',
action: '立即生成',
path: '/conversation',