1
This commit is contained in:
@@ -1,30 +0,0 @@
|
||||
# Debug Session: ratio-options-not-showing
|
||||
|
||||
## Session ID
|
||||
ratio-options-not-showing
|
||||
|
||||
## Created
|
||||
2026-07-01
|
||||
|
||||
## Symptom
|
||||
用户反馈:GenerateConver.tsx 中比例选项(ratioOptions)不显示,控制台无报错。
|
||||
|
||||
## Hypotheses (待验证假设)
|
||||
|
||||
1. **H1**: `ratioOptions` 默认值未生效 - useState 初始化失败
|
||||
2. **H2**: `getEngine()` 返回的 `data.engine.image` 不存在或为空数组,if 条件未进入
|
||||
3. **H3**: 比例按钮渲染区域被父容器 CSS 隐藏(如 `display: none`, `visibility: hidden`, `overflow: hidden`)
|
||||
4. **H4**: `ratioOptions` 在某处被重置为空数组
|
||||
5. **H5**: 组件条件渲染导致整个比例区域未挂载
|
||||
|
||||
## Evidence Points
|
||||
- EP1: 检查 `ratioOptions` 初始值是否为 8 个元素的数组
|
||||
- EP2: 检查 `getEngine()` 返回后 `data.engine.image` 是否存在
|
||||
- EP3: 检查渲染区域父容器的 CSS 是否有隐藏属性
|
||||
- EP4: 搜索代码中是否有 `setRatioOptions([])` 调用
|
||||
|
||||
## Status
|
||||
[OPEN] - 调试中
|
||||
|
||||
## Log File
|
||||
`trae-debug-log-ratio-options-not-showing.ndjson`
|
||||
|
||||
+114
-114
File diff suppressed because one or more lines are too long
Vendored
+36
-36
@@ -1,37 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>民众智创</title>
|
||||
<script>
|
||||
(function() {
|
||||
var cached = localStorage.getItem('siteInfo');
|
||||
if (cached) {
|
||||
try {
|
||||
var info = JSON.parse(cached);
|
||||
if (info.siteName) {
|
||||
document.title = info.siteName;
|
||||
}
|
||||
if (info.siteLogo) {
|
||||
var link = document.querySelector('link[rel="icon"]');
|
||||
if (link) {
|
||||
link.href = info.siteLogo;
|
||||
link.type = 'image/png';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-DUQKfJjB.js"></script>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>民众智创</title>
|
||||
<script>
|
||||
(function() {
|
||||
var cached = localStorage.getItem('siteInfo');
|
||||
if (cached) {
|
||||
try {
|
||||
var info = JSON.parse(cached);
|
||||
if (info.siteName) {
|
||||
document.title = info.siteName;
|
||||
}
|
||||
if (info.siteLogo) {
|
||||
var link = document.querySelector('link[rel="icon"]');
|
||||
if (link) {
|
||||
link.href = info.siteLogo;
|
||||
link.type = 'image/png';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-DjHXCPu7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>␍
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -35,6 +35,7 @@ import CreativePlazaPage from './pages/CreativePlazaPage';
|
||||
import TeamManagementPage from './pages/TeamManagementPage';
|
||||
import JoinTeamPage from './pages/JoinTeamPage';
|
||||
import PrivatePortraitAuthorizeResult from './pages/PrivatePortraitAuthorizeResult';
|
||||
import PrivatePortraitVirtualMaterialPage from './pages/PrivatePortraitVirtualMaterialPage';
|
||||
import { useAuthStore } from './store/useAuthStore';
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { user, loading, checkAuth } = useAuthStore();
|
||||
@@ -124,6 +125,7 @@ const App = () => {
|
||||
<Route path="authorization" element={<AuthorizationPage />} />
|
||||
<Route path="authoriza-waiting" element={<AuthorizationWaitingPage />} />
|
||||
<Route path="materials" element={<MaterialListPage />} />
|
||||
<Route path="materials/private-portrait-virtual" element={<PrivatePortraitVirtualMaterialPage />} />
|
||||
<Route path="consume" element={<ConsumePage />} />
|
||||
<Route path="popular" element={<PopularPage />} />
|
||||
<Route path="authacc" element={<AuthAccountPage />} />
|
||||
|
||||
@@ -780,16 +780,25 @@ export async function getPrivatePortraitValidateSession(sessionId: string): Prom
|
||||
return api.get<PrivatePortraitValidateSession>(`/private-portrait/validate-sessions/${sessionId}`);
|
||||
}
|
||||
|
||||
export async function createPrivatePortraitAsset(projectId: string, payload: { url: string; assetType?: string; name?: string | null }): Promise<PrivatePortraitAsset> {
|
||||
return api.post<PrivatePortraitAsset>(`/private-portrait/projects/${projectId}/assets`, { url: payload.url, asset_type: payload.assetType || 'Image', name: payload.name || null });
|
||||
export async function createPrivatePortraitAsset(projectId: string, payload: { url: string; assetType?: string; name?: string | null; videoDuration?: number | null; videoCoverUrl?: string | null; fileSize?: number | null; mimeType?: string | null }): Promise<PrivatePortraitAsset> {
|
||||
return api.post<PrivatePortraitAsset>(`/private-portrait/projects/${projectId}/assets`, {
|
||||
url: payload.url,
|
||||
asset_type: payload.assetType || 'Image',
|
||||
name: payload.name || null,
|
||||
video_duration: payload.videoDuration ?? null,
|
||||
video_cover_url: payload.videoCoverUrl || null,
|
||||
file_size: payload.fileSize ?? null,
|
||||
mime_type: payload.mimeType || null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPrivatePortraitAssets(projectId: string, params: { page?: number; pageSize?: number; status?: string; keyword?: string } = {}): Promise<PrivatePortraitAssetListOut> {
|
||||
export async function getPrivatePortraitAssets(projectId: string, params: { page?: number; pageSize?: number; status?: string; keyword?: string; assetType?: string } = {}): Promise<PrivatePortraitAssetListOut> {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', String(params.page || 1));
|
||||
query.set('page_size', String(params.pageSize || 20));
|
||||
if (params.status) query.set('status', params.status);
|
||||
if (params.keyword) query.set('keyword', params.keyword);
|
||||
if (params.assetType) query.set('asset_type', params.assetType);
|
||||
return api.get<PrivatePortraitAssetListOut>(`/private-portrait/projects/${projectId}/assets?${query.toString()}`);
|
||||
}
|
||||
|
||||
@@ -801,15 +810,90 @@ export async function deletePrivatePortraitAsset(assetId: string): Promise<void>
|
||||
await api.delete(`/private-portrait/assets/${assetId}`);
|
||||
}
|
||||
|
||||
export async function getPrivatePortraitSelectableAssets(params: { projectId?: string; keyword?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitSelectableAssetListOut> {
|
||||
export async function getPrivatePortraitSelectableAssets(params: { projectId?: string; keyword?: string; page?: number; pageSize?: number; assetType?: string } = {}): Promise<PrivatePortraitSelectableAssetListOut> {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', String(params.page || 1));
|
||||
query.set('page_size', String(params.pageSize || 20));
|
||||
if (params.projectId) query.set('project_id', params.projectId);
|
||||
if (params.keyword) query.set('keyword', params.keyword);
|
||||
if (params.assetType) query.set('asset_type', params.assetType);
|
||||
return api.get<PrivatePortraitSelectableAssetListOut>(`/private-portrait/selectable-assets?${query.toString()}`);
|
||||
}
|
||||
|
||||
|
||||
// ── Private Portrait Virtual Library ─────────────────────
|
||||
export async function getPrivatePortraitVirtualConfig(): Promise<PrivatePortraitConfig> {
|
||||
return api.get<PrivatePortraitConfig>('/private-portrait/virtual/config');
|
||||
}
|
||||
|
||||
export async function getPrivatePortraitVirtualProjects(params: { page?: number; pageSize?: number; keyword?: string; status?: string } = {}): Promise<PrivatePortraitProjectListOut> {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', String(params.page || 1));
|
||||
query.set('page_size', String(params.pageSize || 20));
|
||||
if (params.keyword) query.set('keyword', params.keyword);
|
||||
if (params.status) query.set('status', params.status);
|
||||
return api.get<PrivatePortraitProjectListOut>(`/private-portrait/virtual-projects?${query.toString()}`);
|
||||
}
|
||||
|
||||
export async function createPrivatePortraitVirtualProject(payload: { name: string; description?: string | null }): Promise<PrivatePortraitProject> {
|
||||
return api.post<PrivatePortraitProject>('/private-portrait/virtual-projects', {
|
||||
name: payload.name,
|
||||
description: payload.description || null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updatePrivatePortraitVirtualProject(projectId: string, payload: { name?: string; description?: string | null; status?: string }): Promise<PrivatePortraitProject> {
|
||||
return api.put<PrivatePortraitProject>(`/private-portrait/virtual-projects/${projectId}`, payload);
|
||||
}
|
||||
|
||||
export async function deletePrivatePortraitVirtualProject(projectId: string): Promise<void> {
|
||||
await api.delete(`/private-portrait/virtual-projects/${projectId}`);
|
||||
}
|
||||
|
||||
export async function createPrivatePortraitVirtualAsset(projectId: string, payload: { url: string; assetType?: string; name?: string | null; videoDuration?: number | null; videoCoverUrl?: string | null; fileSize?: number | null; mimeType?: string | null }): Promise<PrivatePortraitAsset> {
|
||||
return api.post<PrivatePortraitAsset>(`/private-portrait/virtual-projects/${projectId}/assets`, {
|
||||
url: payload.url,
|
||||
asset_type: payload.assetType || 'Image',
|
||||
name: payload.name || null,
|
||||
video_duration: payload.videoDuration ?? null,
|
||||
video_cover_url: payload.videoCoverUrl || null,
|
||||
file_size: payload.fileSize ?? null,
|
||||
mime_type: payload.mimeType || null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPrivatePortraitVirtualAssets(projectId: string, params: { page?: number; pageSize?: number; status?: string; keyword?: string; assetType?: string } = {}): Promise<PrivatePortraitAssetListOut> {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', String(params.page || 1));
|
||||
query.set('page_size', String(params.pageSize || 20));
|
||||
if (params.status) query.set('status', params.status);
|
||||
if (params.keyword) query.set('keyword', params.keyword);
|
||||
if (params.assetType) query.set('asset_type', params.assetType);
|
||||
return api.get<PrivatePortraitAssetListOut>(`/private-portrait/virtual-projects/${projectId}/assets?${query.toString()}`);
|
||||
}
|
||||
|
||||
export async function getPrivatePortraitVirtualAsset(assetId: string): Promise<PrivatePortraitAsset> {
|
||||
return api.get<PrivatePortraitAsset>(`/private-portrait/virtual-assets/${assetId}`);
|
||||
}
|
||||
|
||||
export async function syncPrivatePortraitVirtualAsset(assetId: string): Promise<PrivatePortraitAsset> {
|
||||
return api.post<PrivatePortraitAsset>(`/private-portrait/virtual-assets/${assetId}/sync`);
|
||||
}
|
||||
|
||||
export async function deletePrivatePortraitVirtualAsset(assetId: string): Promise<void> {
|
||||
await api.delete(`/private-portrait/virtual-assets/${assetId}`);
|
||||
}
|
||||
|
||||
export async function getPrivatePortraitVirtualSelectableAssets(params: { projectId?: string; keyword?: string; page?: number; pageSize?: number; assetType?: string } = {}): Promise<PrivatePortraitSelectableAssetListOut> {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', String(params.page || 1));
|
||||
query.set('page_size', String(params.pageSize || 20));
|
||||
if (params.projectId) query.set('project_id', params.projectId);
|
||||
if (params.keyword) query.set('keyword', params.keyword);
|
||||
if (params.assetType) query.set('asset_type', params.assetType);
|
||||
return api.get<PrivatePortraitSelectableAssetListOut>(`/private-portrait/virtual-selectable-assets?${query.toString()}`);
|
||||
}
|
||||
|
||||
// ── Team Management APIs ──────────────────────────────
|
||||
export async function getManagedTeam(): Promise<any> {
|
||||
return api.get('/team/managed');
|
||||
|
||||
@@ -984,7 +984,7 @@ const AppLayout: React.FC = () => {
|
||||
placement="left"
|
||||
onClose={() => setMobileMenuOpen(false)}
|
||||
open={mobileMenuOpen}
|
||||
width={280}
|
||||
size={280}
|
||||
closable={true}
|
||||
className="mobile-menu-drawer"
|
||||
styles={{
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Modal, Tooltip, Empty, Input, List, Spin, Tag, Typography, message } from 'antd';
|
||||
import { HistoryOutlined, UserOutlined, FolderOpenOutlined, PlusOutlined, CheckOutlined, ReloadOutlined, SearchOutlined, PictureOutlined } from '@ant-design/icons';
|
||||
import { getPrivatePortraitProjects, getPrivatePortraitSelectableAssets } from '../api';
|
||||
import type { PrivatePortraitProject, PrivatePortraitSelectableAsset } from '../types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface UploadSelectorProps {
|
||||
children: React.ReactNode;
|
||||
accept?: string;
|
||||
onLocalSelect?: (files: File[]) => void;
|
||||
onHistorySelect?: (items: any[]) => void;
|
||||
onPortraitSelect?: (items: any[]) => void;
|
||||
uploading?: boolean;
|
||||
tooltipTitle?: string;
|
||||
}
|
||||
|
||||
const UploadSelector: React.FC<UploadSelectorProps> = ({
|
||||
children,
|
||||
accept = 'image/*,video/*',
|
||||
onLocalSelect,
|
||||
onHistorySelect,
|
||||
onPortraitSelect,
|
||||
uploading,
|
||||
tooltipTitle,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleLocalSelect = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && onLocalSelect) {
|
||||
onLocalSelect(Array.from(files));
|
||||
}
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [historyModalVisible, setHistoryModalVisible] = useState(false);
|
||||
const [portraitModalVisible, setPortraitModalVisible] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
if (!uploading) {
|
||||
setModalVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const mockHistoryData = [];
|
||||
|
||||
const [selectedHistoryItems, setSelectedHistoryItems] = useState<number[]>([]);
|
||||
const [selectedPortraitItems, setSelectedPortraitItems] = useState<Map<string, PrivatePortraitSelectableAsset>>(new Map());
|
||||
const [historyActiveTab, setHistoryActiveTab] = useState<'asset' | 'history'>('asset');
|
||||
|
||||
const [portraitProjects, setPortraitProjects] = useState<PrivatePortraitProject[]>([]);
|
||||
const [portraitProjectId, setPortraitProjectId] = useState<string | undefined>();
|
||||
const [portraitKeyword, setPortraitKeyword] = useState('');
|
||||
const [portraitAssets, setPortraitAssets] = useState<PrivatePortraitSelectableAsset[]>([]);
|
||||
const [loadingPortraitProjects, setLoadingPortraitProjects] = useState(false);
|
||||
const [loadingPortraitAssets, setLoadingPortraitAssets] = useState(false);
|
||||
|
||||
const getPreviewUrl = (url?: string | null) => {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:') || url.startsWith('blob:')) {
|
||||
return url;
|
||||
}
|
||||
const base = (import.meta.env.VITE_API_BASE || 'http://localhost:8000').replace(/\/$/, '');
|
||||
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
};
|
||||
|
||||
const loadPortraitProjects = async () => {
|
||||
setLoadingPortraitProjects(true);
|
||||
try {
|
||||
const res = await getPrivatePortraitProjects({ page: 1, pageSize: 100, status: 'active' });
|
||||
setPortraitProjects(res.items || []);
|
||||
if (!portraitProjectId && res.items?.length) {
|
||||
setPortraitProjectId(res.items[0].id);
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载真人素材项目失败');
|
||||
} finally {
|
||||
setLoadingPortraitProjects(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPortraitAssets = async () => {
|
||||
setLoadingPortraitAssets(true);
|
||||
try {
|
||||
const res = await getPrivatePortraitSelectableAssets({ projectId: portraitProjectId, keyword: portraitKeyword.trim() || undefined, page: 1, pageSize: 100 });
|
||||
setPortraitAssets(res.items || []);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载真人素材失败');
|
||||
} finally {
|
||||
setLoadingPortraitAssets(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!portraitModalVisible) return;
|
||||
setSelectedPortraitItems(new Map());
|
||||
loadPortraitProjects();
|
||||
}, [portraitModalVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!portraitModalVisible) return;
|
||||
loadPortraitAssets();
|
||||
}, [portraitModalVisible, portraitProjectId]);
|
||||
|
||||
const togglePortraitAsset = (asset: PrivatePortraitSelectableAsset) => {
|
||||
setSelectedPortraitItems((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(asset.id)) {
|
||||
next.delete(asset.id);
|
||||
} else {
|
||||
next.set(asset.id, asset);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleHistoryItem = (id: number) => {
|
||||
setSelectedHistoryItems(prev =>
|
||||
prev.includes(id) ? prev.filter(item => item !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const confirmHistorySelection = () => {
|
||||
const items = mockHistoryData.filter(item => selectedHistoryItems.includes(item.id));
|
||||
onHistorySelect?.(items);
|
||||
setHistoryModalVisible(false);
|
||||
setSelectedHistoryItems([]);
|
||||
setModalVisible(false);
|
||||
};
|
||||
|
||||
const confirmPortraitSelection = () => {
|
||||
const selected = Array.from(selectedPortraitItems.values());
|
||||
if (!selected.length) {
|
||||
message.warning('请选择至少一个真人素材');
|
||||
return;
|
||||
}
|
||||
const transformedItems = selected.map(item => ({
|
||||
...item,
|
||||
avatar: getPreviewUrl(item.previewUrl),
|
||||
}));
|
||||
onPortraitSelect?.(transformedItems);
|
||||
setPortraitModalVisible(false);
|
||||
setSelectedPortraitItems(new Map());
|
||||
setModalVisible(false);
|
||||
};
|
||||
|
||||
const options = [
|
||||
{
|
||||
key: 'history',
|
||||
label: '历史记录',
|
||||
icon: <HistoryOutlined style={{ fontSize: 20, color: '#6366f1' }} />,
|
||||
description: '从历史上传记录中选择',
|
||||
onClick: () => {
|
||||
setModalVisible(false);
|
||||
setHistoryModalVisible(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'portrait',
|
||||
label: '人像',
|
||||
icon: <UserOutlined style={{ fontSize: 20, color: '#ec4899' }} />,
|
||||
description: '从人像库中选择',
|
||||
onClick: () => {
|
||||
setModalVisible(false);
|
||||
setPortraitModalVisible(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'local',
|
||||
label: '本地选取',
|
||||
icon: <FolderOpenOutlined style={{ fontSize: 20, color: '#10b981' }} />,
|
||||
description: '从本地电脑选择文件',
|
||||
onClick: () => {
|
||||
setModalVisible(false);
|
||||
handleLocalSelect();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
{tooltipTitle ? (
|
||||
<Tooltip title={tooltipTitle}>
|
||||
<div onClick={handleClick} style={{ cursor: 'pointer' }}>
|
||||
{children}
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div onClick={handleClick} style={{ cursor: 'pointer' }}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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
|
||||
title="选择资产素材"
|
||||
open={historyModalVisible}
|
||||
onCancel={() => {
|
||||
setHistoryModalVisible(false);
|
||||
setSelectedHistoryItems([]);
|
||||
}}
|
||||
footer={null}
|
||||
width={"80%"}
|
||||
height={"50%"}
|
||||
centered
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<div
|
||||
onClick={() => setHistoryActiveTab('asset')}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
fontWeight: historyActiveTab === 'asset' ? 600 : 500,
|
||||
color: historyActiveTab === 'asset' ? '#fff' : '#64748b',
|
||||
background: historyActiveTab === 'asset' ? '#6366f1' : '#f1f5f9',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
资产图片
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setHistoryActiveTab('history')}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
fontWeight: historyActiveTab === 'history' ? 600 : 500,
|
||||
color: historyActiveTab === 'history' ? '#fff' : '#64748b',
|
||||
background: historyActiveTab === 'history' ? '#6366f1' : '#f1f5f9',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
历史图片
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ minHeight: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ textAlign: 'center', color: '#94a3b8', fontSize: 14 }}>
|
||||
暂无资产图片
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 16, paddingTop: 16, borderTop: '1px solid #e2e8f0' }}>
|
||||
<div style={{ fontSize: 14, color: '#64748b' }}>
|
||||
已选择 <span style={{ color: '#ef4444', fontWeight: 600 }}>{selectedHistoryItems.length}</span> 个素材
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setHistoryModalVisible(false);
|
||||
setSelectedHistoryItems([]);
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 24px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #e2e8f0',
|
||||
background: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
color: '#64748b',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#cbd5e1';
|
||||
e.currentTarget.style.background = '#f8fafc';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#e2e8f0';
|
||||
e.currentTarget.style.background = '#fff';
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmHistorySelection}
|
||||
style={{
|
||||
padding: '8px 24px',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
background: '#ef4444',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#dc2626';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#ef4444';
|
||||
}}
|
||||
>
|
||||
应用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="选择真人素材库"
|
||||
open={portraitModalVisible}
|
||||
onCancel={() => {
|
||||
setPortraitModalVisible(false);
|
||||
setSelectedPortraitItems(new Map());
|
||||
}}
|
||||
width={920}
|
||||
centered
|
||||
footer={[
|
||||
<button
|
||||
key="cancel"
|
||||
onClick={() => {
|
||||
setPortraitModalVisible(false);
|
||||
setSelectedPortraitItems(new Map());
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 24px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #e2e8f0',
|
||||
background: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
color: '#64748b',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#cbd5e1';
|
||||
e.currentTarget.style.background = '#f8fafc';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#e2e8f0';
|
||||
e.currentTarget.style.background = '#fff';
|
||||
}}
|
||||
>取消</button>,
|
||||
<button
|
||||
key="ok"
|
||||
onClick={confirmPortraitSelection}
|
||||
style={{
|
||||
padding: '8px 24px',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
background: '#8b5cf6',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#7c3aed';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#8b5cf6';
|
||||
}}
|
||||
>
|
||||
添加选中素材({selectedPortraitItems.size})
|
||||
</button>,
|
||||
]}
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 16, minHeight: 480 }}>
|
||||
<div style={{ border: '1px solid #eef0f4', borderRadius: 12, padding: 12, background: '#fafafa' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12, alignItems: 'center' }}>
|
||||
<Text strong>项目组</Text>
|
||||
<button
|
||||
onClick={loadPortraitProjects}
|
||||
disabled={loadingPortraitProjects}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #e2e8f0',
|
||||
background: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
color: '#64748b',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<ReloadOutlined style={{ fontSize: 12 }} />
|
||||
</button>
|
||||
</div>
|
||||
<Spin spinning={loadingPortraitProjects}>
|
||||
<List
|
||||
dataSource={portraitProjects}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无项目组" /> }}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
onClick={() => setPortraitProjectId(item.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 10,
|
||||
marginBottom: 6,
|
||||
border: portraitProjectId === item.id ? '1px solid #8b5cf6' : '1px solid transparent',
|
||||
background: portraitProjectId === item.id ? '#f5f3ff' : '#fff',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
<Text strong ellipsis style={{ display: 'block' }}>{item.name}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>Active {item.activeAssetCount || 0}</Text>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 12 }}>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索素材名称"
|
||||
value={portraitKeyword}
|
||||
onChange={(e) => setPortraitKeyword(e.target.value)}
|
||||
onPressEnter={loadPortraitAssets}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={loadPortraitAssets}
|
||||
disabled={loadingPortraitAssets}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #e2e8f0',
|
||||
background: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
color: '#64748b',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<ReloadOutlined />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<Spin spinning={loadingPortraitAssets}>
|
||||
{portraitAssets.length === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无可选 Active 真人素材" style={{ marginTop: 120 }} />
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 12, maxHeight: 420, overflowY: 'auto', paddingRight: 4 }}>
|
||||
{portraitAssets.map((asset) => {
|
||||
const active = selectedPortraitItems.has(asset.id);
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
onClick={() => togglePortraitAsset(asset)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
border: active ? '2px solid #8b5cf6' : '1px solid #edf0f5',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
background: '#fff',
|
||||
boxShadow: active ? '0 8px 20px rgba(139,92,246,0.18)' : '0 4px 12px rgba(15,23,42,0.04)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div style={{ aspectRatio: '1 / 1', background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{asset.previewUrl ? (
|
||||
<img src={getPreviewUrl(asset.previewUrl)} alt={asset.name || '真人素材'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<PictureOutlined style={{ fontSize: 32, color: '#94a3b8' }} />
|
||||
)}
|
||||
</div>
|
||||
{active && (
|
||||
<div style={{ position: 'absolute', top: 8, right: 8, width: 24, height: 24, borderRadius: 12, background: '#8b5cf6', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<CheckOutlined />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ padding: 10 }}>
|
||||
<Text strong ellipsis style={{ display: 'block' }}>{asset.name || '未命名素材'}</Text>
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 6 }}>
|
||||
<Tag color="green">Active</Tag>
|
||||
<Tag>{asset.projectName}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadSelector;
|
||||
@@ -22,6 +22,8 @@ import bg2 from '../assets/bg2.png';
|
||||
import bg3 from '../assets/bg3.png';
|
||||
import text from '../assets/testb.png';
|
||||
|
||||
import UploadSelector from '../components/UploadSelector';
|
||||
|
||||
|
||||
|
||||
import {
|
||||
@@ -1255,6 +1257,8 @@ const AIChatPage: React.FC = () => {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
if (isAudio) {
|
||||
@@ -1293,7 +1297,6 @@ const AIChatPage: React.FC = () => {
|
||||
}];
|
||||
const labels = generateMediaLabels(newList);
|
||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||
message.success(`${isImage ? '图片' : (isAudio ? '音频' : '视频')}上传成功`);
|
||||
} catch (error) {
|
||||
message.error('上传失败');
|
||||
} finally {
|
||||
@@ -1303,6 +1306,118 @@ const AIChatPage: React.FC = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const doUpload = async (file: File): Promise<false | { name: string; type: 'image' | 'video' | 'audio'; url: string; label: string; duration?: number }> => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
const isAudio = file.type.startsWith('audio/');
|
||||
|
||||
if (!isImage && !isVideo && !isAudio) {
|
||||
message.error('仅支持图片、视频或音频文件');
|
||||
return false;
|
||||
}
|
||||
|
||||
const maxMB = isVideo ? 100 : (isAudio ? 50 : 10);
|
||||
if (file.size / 1024 / 1024 > maxMB) {
|
||||
message.error(`${isVideo ? '视频' : (isAudio ? '音频' : '图片')}大小不能超过${maxMB}MB`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAudio) {
|
||||
const audioExt = file.name.split('.').pop()?.toLowerCase();
|
||||
if (!['wav', 'mp3'].includes(audioExt || '')) {
|
||||
message.error('音频仅支持wav和mp3格式');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let videoDuration = 0;
|
||||
let audioDuration = 0;
|
||||
if (isVideo) {
|
||||
try {
|
||||
videoDuration = await getVideoDuration(file);
|
||||
if (videoDuration < 2) {
|
||||
message.error('视频素材最短不能少于 2 秒');
|
||||
return false;
|
||||
}
|
||||
const latestMedia = useAppStore.getState().currentMedia;
|
||||
const existingVideoDuration = latestMedia
|
||||
.filter((m) => m.type === 'video')
|
||||
.reduce((sum, m) => sum + (m.duration || 0), 0);
|
||||
if (existingVideoDuration + videoDuration > 15) {
|
||||
message.error(`所有视频素材总时长不能超过 15 秒,当前 ${(existingVideoDuration + videoDuration).toFixed(1)} 秒`);
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
message.error('无法获取视频信息,请检查文件是否损坏');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isAudio) {
|
||||
try {
|
||||
audioDuration = await getAudioDuration(file);
|
||||
if (audioDuration < 2) {
|
||||
message.error('音频素材最短不能少于 2 秒');
|
||||
return false;
|
||||
}
|
||||
const latestMedia = useAppStore.getState().currentMedia;
|
||||
const existingAudioDuration = latestMedia
|
||||
.filter((m) => m.type === 'audio')
|
||||
.reduce((sum, m) => sum + (m.duration || 0), 0);
|
||||
if (existingAudioDuration + audioDuration > 15) {
|
||||
message.error(`所有音频素材总时长不能超过 15 秒,当前 ${(existingAudioDuration + audioDuration).toFixed(1)} 秒`);
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
message.error('无法获取音频信息,请检查文件是否损坏');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const uploadFn = isImage ? uploadImage : (isAudio ? uploadAudio : uploadVideo);
|
||||
const res = await uploadFn(file);
|
||||
const mediaType: 'image' | 'video' | 'audio' = isImage ? 'image' : (isAudio ? 'audio' : 'video');
|
||||
return {
|
||||
name: file.name,
|
||||
type: mediaType,
|
||||
url: res.url,
|
||||
label: '',
|
||||
...(isVideo && { duration: videoDuration }),
|
||||
...(isAudio && { duration: audioDuration }),
|
||||
};
|
||||
} catch (error) {
|
||||
message.error('上传失败');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchUpload = async (files: File[]) => {
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
setUploading(true);
|
||||
|
||||
const result = await doUpload(file);
|
||||
|
||||
if (result) {
|
||||
const latestMedia = useAppStore.getState().currentMedia;
|
||||
const newList = [...latestMedia, result];
|
||||
const labels = generateMediaLabels(newList);
|
||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
message.success(`成功上传${successCount}个文件${failCount > 0 ? `,${failCount}个文件上传失败` : ''}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrivatePortraitAssetsSelected = (assets: PrivatePortraitSelectableAsset[]) => {
|
||||
if (mediaType !== 'video') {
|
||||
@@ -1346,12 +1461,12 @@ const AIChatPage: React.FC = () => {
|
||||
};
|
||||
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
// const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
// if (e.key === 'Enter' && !e.shiftKey) {
|
||||
// e.preventDefault();
|
||||
// handleSend();
|
||||
// }
|
||||
// };
|
||||
|
||||
// 检测光标前的 @ 符号
|
||||
const checkMention = (textarea: HTMLTextAreaElement, value: string) => {
|
||||
@@ -1511,7 +1626,7 @@ const AIChatPage: React.FC = () => {
|
||||
{/* 隐藏的音频播放器 */}
|
||||
<audio
|
||||
id="audio-player"
|
||||
src={playingAudioUrl || ''}
|
||||
src={playingAudioUrl || null}
|
||||
autoPlay
|
||||
onEnded={() => setPlayingAudioUrl(null)}
|
||||
style={{ display: 'none' }}
|
||||
@@ -2392,63 +2507,75 @@ const AIChatPage: React.FC = () => {
|
||||
>
|
||||
{/* 没有上传时的卡片样式 */}
|
||||
{currentMedia.length === 0 && (
|
||||
<Upload
|
||||
<UploadSelector
|
||||
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*,audio/*'}
|
||||
showUploadList={false}
|
||||
beforeUpload={handleUpload}
|
||||
>
|
||||
<Tooltip title={mediaType === 'image'
|
||||
onLocalSelect={handleBatchUpload}
|
||||
onHistorySelect={(items) => {
|
||||
const newMedia = items.map((item: any) => ({
|
||||
name: item.name,
|
||||
type: item.type as 'image' | 'video' | 'audio',
|
||||
url: '',
|
||||
label: '',
|
||||
}));
|
||||
setCurrentMedia([...currentMedia, ...newMedia]);
|
||||
message.success(`成功添加${items.length}个历史记录`);
|
||||
}}
|
||||
onPortraitSelect={(items) => {
|
||||
handlePrivatePortraitAssetsSelected(items as any);
|
||||
}}
|
||||
uploading={uploading}
|
||||
tooltipTitle={mediaType === 'image'
|
||||
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
|
||||
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount},
|
||||
视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}${maxAudio > 0 ? `,
|
||||
音频${currentMedia.filter(m => m.type === 'audio').length}/${maxAudio}` : ''}`
|
||||
}>
|
||||
<div
|
||||
style={{
|
||||
width: 54,
|
||||
height: 74,
|
||||
borderRadius: 7,
|
||||
border: '1px solid rgba(231, 234, 240, 0.95)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.25s ease',
|
||||
background: '#ffffff',
|
||||
flexDirection: 'column',
|
||||
gap: 5,
|
||||
transform: 'rotate(-7deg)',
|
||||
boxShadow: '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#D7DDE7';
|
||||
e.currentTarget.style.background = 'linear-gradient(180deg, #ffffff 0%, #F7F8FA 100%)';
|
||||
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)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(231, 234, 240, 0.95)';
|
||||
e.currentTarget.style.background = 'linear-gradient(180deg, #ffffff 0%, #FAFBFC 100%)';
|
||||
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)';
|
||||
}}
|
||||
>
|
||||
{uploading ? (
|
||||
<LoadingOutlined style={{ fontSize: 17, color: '#667085' }} />
|
||||
) : (
|
||||
<>
|
||||
<PlusOutlined style={{ fontSize: 18, color: '#667085', lineHeight: 1 }} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 14px)', columnGap: 2, justifyContent: 'center', color: '#344054', fontSize: 12, fontWeight: 700, lineHeight: 1.05, letterSpacing: 0 }}>
|
||||
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}><span>参</span><span>考</span></span>
|
||||
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}><span>内</span><span>容</span></span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Upload>
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 54,
|
||||
height: 74,
|
||||
borderRadius: 7,
|
||||
border: '1px solid rgba(231, 234, 240, 0.95)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.25s ease',
|
||||
background: '#ffffff',
|
||||
flexDirection: 'column',
|
||||
gap: 5,
|
||||
transform: 'rotate(-7deg)',
|
||||
boxShadow: '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#D7DDE7';
|
||||
e.currentTarget.style.background = 'linear-gradient(180deg, #ffffff 0%, #F7F8FA 100%)';
|
||||
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)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(231, 234, 240, 0.95)';
|
||||
e.currentTarget.style.background = 'linear-gradient(180deg, #ffffff 0%, #FAFBFC 100%)';
|
||||
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)';
|
||||
}}
|
||||
>
|
||||
{uploading ? (
|
||||
<LoadingOutlined style={{ fontSize: 17, color: '#667085' }} />
|
||||
) : (
|
||||
<>
|
||||
<PlusOutlined style={{ fontSize: 18, color: '#667085', lineHeight: 1 }} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 14px)', columnGap: 2, justifyContent: 'center', color: '#344054', fontSize: 12, fontWeight: 700, lineHeight: 1.05, letterSpacing: 0 }}>
|
||||
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}><span>参</span><span>考</span></span>
|
||||
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}><span>内</span><span>容</span></span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</UploadSelector>
|
||||
)}
|
||||
{mediaType === 'video' && currentMedia.length === 0 && (
|
||||
{/* {mediaType === 'video' && currentMedia.length === 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setPrivateAssetPickerOpen(true)}
|
||||
@@ -2456,7 +2583,7 @@ const AIChatPage: React.FC = () => {
|
||||
>
|
||||
真人素材库
|
||||
</Button>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
{/* 层叠附件展示 - 鼠标移入向右排列展开 */}
|
||||
{currentMedia.length > 0 && (
|
||||
@@ -2560,50 +2687,62 @@ const AIChatPage: React.FC = () => {
|
||||
|
||||
{/* 右下角圆形+上传按钮 */}
|
||||
{currentMedia.length > 0 && (
|
||||
<Upload
|
||||
<UploadSelector
|
||||
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*,audio/*'}
|
||||
showUploadList={false}
|
||||
beforeUpload={handleUpload}
|
||||
>
|
||||
<Tooltip title={mediaType === 'image'
|
||||
onLocalSelect={handleBatchUpload}
|
||||
onHistorySelect={(items) => {
|
||||
const newMedia = items.map((item: any) => ({
|
||||
name: item.name,
|
||||
type: item.type as 'image' | 'video' | 'audio',
|
||||
url: '',
|
||||
label: '',
|
||||
}));
|
||||
setCurrentMedia([...currentMedia, ...newMedia]);
|
||||
message.success(`成功添加${items.length}个历史记录`);
|
||||
}}
|
||||
onPortraitSelect={(items) => {
|
||||
handlePrivatePortraitAssetsSelected(items as any);
|
||||
}}
|
||||
uploading={uploading}
|
||||
tooltipTitle={mediaType === 'image'
|
||||
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
|
||||
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount},
|
||||
视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}${maxAudio > 0 ? `,
|
||||
音频${currentMedia.filter(m => m.type === 'audio').length}/${maxAudio}` : ''}`
|
||||
}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 50,
|
||||
background: '#ffffff',
|
||||
border: '1px solid rgba(231, 234, 240, 0.95)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 2px 8px rgba(47, 52, 64, 0.08)',
|
||||
zIndex: 100,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#8b5cf6';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(139, 92, 246, 0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(231, 234, 240, 0.95)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(47, 52, 64, 0.08)';
|
||||
}}
|
||||
>
|
||||
<PlusOutlined style={{ fontSize: 14, color: '#8b5cf6', lineHeight: 1 }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Upload>
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 50,
|
||||
background: '#ffffff',
|
||||
border: '1px solid rgba(231, 234, 240, 0.95)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 2px 8px rgba(47, 52, 64, 0.08)',
|
||||
zIndex: 100,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#8b5cf6';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(139, 92, 246, 0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(231, 234, 240, 0.95)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(47, 52, 64, 0.08)';
|
||||
}}
|
||||
>
|
||||
<PlusOutlined style={{ fontSize: 14, color: '#8b5cf6', lineHeight: 1 }} />
|
||||
</div>
|
||||
</UploadSelector>
|
||||
)}
|
||||
{mediaType === 'video' && currentMedia.length > 0 && (
|
||||
{/* {mediaType === 'video' && currentMedia.length > 0 && (
|
||||
<Tooltip title="选择真人素材库">
|
||||
<div
|
||||
onClick={(e) => { e.stopPropagation(); setPrivateAssetPickerOpen(true); }}
|
||||
@@ -2630,7 +2769,7 @@ const AIChatPage: React.FC = () => {
|
||||
真
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -2665,7 +2804,23 @@ const AIChatPage: React.FC = () => {
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onKeyPress={handleKeyPress}
|
||||
onPaste={(e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const imageFiles: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
const file = items[i].getAsFile();
|
||||
if (file) imageFiles.push(file);
|
||||
}
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
imageFiles.forEach(async (file) => {
|
||||
await handleUpload(file);
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder={composerPlaceholder}
|
||||
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||
style={{
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
getRecordsPage,
|
||||
} from "../api";
|
||||
import { formatDate } from "../utils/formatDate";
|
||||
import UploadSelector from "../components/UploadSelector";
|
||||
import { generateUUID } from "../utils/uuid";
|
||||
|
||||
// const calcVideoCredits = (duration: number, resolution: Resolution): number => {
|
||||
@@ -260,10 +261,58 @@ const GeneratePage: React.FC = () => {
|
||||
const [creditRatios, setCreditRatios] = useState<any>([]);
|
||||
const [cimage, setCimage] = useState<any>([]);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const handlePasteUpload = async (file: File) => {
|
||||
const isImage = file.type.startsWith("image/");
|
||||
const isVideo = file.type.startsWith("video/");
|
||||
if (!isImage && !isVideo) {
|
||||
message.error("仅支持图片或视频文件");
|
||||
return false;
|
||||
}
|
||||
const maxMB = isVideo ? 100 : 10;
|
||||
if (file.size / 1024 / 1024 > maxMB) {
|
||||
message.error(
|
||||
`${isVideo ? "视频" : "图片"}大小不能超过${maxMB}MB`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const imageCount = references.filter(
|
||||
(r) => r.type === "image",
|
||||
).length;
|
||||
const videoCount = references.filter(
|
||||
(r) => r.type === "video",
|
||||
).length;
|
||||
if (isImage && imageCount >= 10) {
|
||||
message.error("最多上传10张图片");
|
||||
return false;
|
||||
}
|
||||
if (isVideo && videoCount >= 3) {
|
||||
message.error("最多上传3个视频");
|
||||
return false;
|
||||
}
|
||||
setUploading(true);
|
||||
const uploadFn = isImage ? uploadImage : uploadVideo;
|
||||
try {
|
||||
const res = await uploadFn(file);
|
||||
const typeLabel = isImage ? "图片" : "视频";
|
||||
const typeCount = isImage
|
||||
? imageCount + 1
|
||||
: videoCount + 1;
|
||||
setReferences((prev) => [
|
||||
...prev,
|
||||
{
|
||||
url: res.url,
|
||||
type: isImage ? "image" : "video",
|
||||
name: `${typeLabel}${typeCount}`,
|
||||
},
|
||||
]);
|
||||
message.success(`${typeLabel}上传成功`);
|
||||
} catch {
|
||||
message.error("上传失败");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 点击空白处关闭图片设置浮层
|
||||
useEffect(() => {
|
||||
@@ -1772,97 +1821,73 @@ const GeneratePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Upload
|
||||
<UploadSelector
|
||||
accept="image/*,video/*"
|
||||
showUploadList={false}
|
||||
multiple
|
||||
beforeUpload={(file) => {
|
||||
const isImage = file.type.startsWith("image/");
|
||||
const isVideo = file.type.startsWith("video/");
|
||||
if (!isImage && !isVideo) {
|
||||
message.error("仅支持图片或视频文件");
|
||||
return false;
|
||||
}
|
||||
const maxMB = isVideo ? 100 : 10;
|
||||
if (file.size / 1024 / 1024 > maxMB) {
|
||||
message.error(
|
||||
`${isVideo ? "视频" : "图片"}大小不能超过${maxMB}MB`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const imageCount = references.filter(
|
||||
(r) => r.type === "image",
|
||||
).length;
|
||||
const videoCount = references.filter(
|
||||
(r) => r.type === "video",
|
||||
).length;
|
||||
if (isImage && imageCount >= 10) {
|
||||
message.error("最多上传10张图片");
|
||||
return false;
|
||||
}
|
||||
if (isVideo && videoCount >= 3) {
|
||||
message.error("最多上传3个视频");
|
||||
return false;
|
||||
}
|
||||
setUploading(true);
|
||||
const uploadFn = isImage ? uploadImage : uploadVideo;
|
||||
uploadFn(file)
|
||||
.then((res) => {
|
||||
const typeLabel = isImage ? "图片" : "视频";
|
||||
const typeCount = isImage
|
||||
? imageCount + 1
|
||||
: videoCount + 1;
|
||||
setReferences((prev) => [
|
||||
...prev,
|
||||
{
|
||||
url: res.url,
|
||||
type: isImage ? "image" : "video",
|
||||
name: `${typeLabel}${typeCount}`,
|
||||
},
|
||||
]);
|
||||
message.success(`${typeLabel}上传成功`);
|
||||
})
|
||||
.catch(() => message.error("上传失败"))
|
||||
.finally(() => setUploading(false));
|
||||
return false;
|
||||
onLocalSelect={(files) => {
|
||||
files.forEach(async (file) => {
|
||||
await handlePasteUpload(file);
|
||||
});
|
||||
}}
|
||||
onHistorySelect={(items) => {
|
||||
items.forEach((item: any) => {
|
||||
setReferences(prev => [...prev, {
|
||||
url: '',
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
}]);
|
||||
});
|
||||
message.success(`成功添加${items.length}个历史记录`);
|
||||
}}
|
||||
onPortraitSelect={(items) => {
|
||||
items.forEach((item: any) => {
|
||||
setReferences(prev => [...prev, {
|
||||
url: item.previewUrl || '',
|
||||
type: 'image',
|
||||
name: item.name || '真人素材',
|
||||
source: 'private_portrait_asset',
|
||||
private_asset_id: item.id,
|
||||
label: '',
|
||||
}]);
|
||||
});
|
||||
message.success(`已添加 ${items.length} 个真人素材参考`);
|
||||
}}
|
||||
uploading={uploading}
|
||||
tooltipTitle={`参考内容(${references.length}/10)`}
|
||||
>
|
||||
<Tooltip title={`参考内容(${references.length}/10)`}>
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
border: "1.5px dashed #d9d9d9",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "#6366f1";
|
||||
e.currentTarget.style.background =
|
||||
"rgba(99,102,241,0.04)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "#d9d9d9";
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
{uploading ? (
|
||||
<LoadingOutlined
|
||||
style={{ fontSize: 18, color: "#6366f1" }}
|
||||
/>
|
||||
) : (
|
||||
<PlusOutlined
|
||||
style={{ fontSize: 18, color: "#94a3b8" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Upload>
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
border: "1.5px dashed #d9d9d9",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "#6366f1";
|
||||
e.currentTarget.style.background =
|
||||
"rgba(99,102,241,0.04)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "#d9d9d9";
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
{uploading ? (
|
||||
<LoadingOutlined
|
||||
style={{ fontSize: 18, color: "#6366f1" }}
|
||||
/>
|
||||
) : (
|
||||
<PlusOutlined
|
||||
style={{ fontSize: 18, color: "#94a3b8" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</UploadSelector>
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
@@ -1885,6 +1910,23 @@ const GeneratePage: React.FC = () => {
|
||||
}
|
||||
setShowMention(false);
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const imageFiles: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
const file = items[i].getAsFile();
|
||||
if (file) imageFiles.push(file);
|
||||
}
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
imageFiles.forEach(async (file) => {
|
||||
await handlePasteUpload(file);
|
||||
});
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
placeholder="上传参考素材、输入文字,自由组合图、文多元素。输入 @ 可引用参考内容..."
|
||||
maxLength={500}
|
||||
@@ -1897,7 +1939,7 @@ const GeneratePage: React.FC = () => {
|
||||
resize: "none",
|
||||
caretColor: "#6366f1",
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* @ mention dropdown */}
|
||||
@@ -4547,7 +4589,7 @@ const GeneratePage: React.FC = () => {
|
||||
footer={null}
|
||||
width={480}
|
||||
centered
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
closable={false}
|
||||
title={null}
|
||||
styles={{
|
||||
|
||||
@@ -14,9 +14,7 @@ import {
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getmedit ,getHomeCaseHeader,getHomeCaseButton} from '../api';
|
||||
|
||||
import hot from '../assets/homebtn1.png';
|
||||
import mashup from '../assets/homebtn2.png';
|
||||
import aicreate from '../assets/homebtn3.png';
|
||||
|
||||
|
||||
|
||||
// 把 ISO 时间格式化成 MM-DD HH:mm(与图片一致)
|
||||
@@ -45,6 +43,7 @@ const HomePage: React.FC = () => {
|
||||
const [caseAssets, setCaseAssets] = useState<any[]>([]);
|
||||
const [previewAsset, setPreviewAsset] = useState<any>(null);
|
||||
const previewVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const [activeContentTab, setActiveContentTab] = useState<'works' | 'cases'>('works');
|
||||
|
||||
useEffect(() => {
|
||||
getHomeCaseHeader().then((res: any) => {
|
||||
@@ -96,22 +95,29 @@ const HomePage: React.FC = () => {
|
||||
|
||||
|
||||
const aiEntries = [
|
||||
{
|
||||
icon: <FileTextOutlined style={{ fontSize: 24 }} />,
|
||||
title: '项目创建',
|
||||
description: '新建项目、设置图文视频参数、核对信息并生成素材',
|
||||
action: '立即创作',
|
||||
path: '/projects',
|
||||
},
|
||||
{
|
||||
icon: <FileTextOutlined style={{ fontSize: 24, color: '#6366f1' }} />,
|
||||
icon: <FileTextOutlined style={{ fontSize: 24 }} />,
|
||||
title: '爆款复刻',
|
||||
description: '上传参考视频与产品图片,一键复刻爆款视频开头',
|
||||
action: '立即创作',
|
||||
path: '/initial',
|
||||
},
|
||||
{
|
||||
icon: <ScissorOutlined style={{ fontSize: 24, color: '#f97316' }} />,
|
||||
icon: <ScissorOutlined style={{ fontSize: 24 }} />,
|
||||
title: '拆镜复刻',
|
||||
description: '精细化镜头复刻工具,拆分参考视频单镜头独立复刻,提升素材原创度,规避素材同质化',
|
||||
action: '开始混剪',
|
||||
action: '开始拆镜',
|
||||
path: '/removelens',
|
||||
},
|
||||
{
|
||||
icon: <RobotOutlined style={{ fontSize: 24, color: '#10b981' }} />,
|
||||
icon: <RobotOutlined style={{ fontSize: 24 }} />,
|
||||
title: 'AI成片',
|
||||
description: '输入想法、剧本或上传参考,智能生成视频/图片',
|
||||
action: '立即生成',
|
||||
@@ -132,13 +138,6 @@ const HomePage: React.FC = () => {
|
||||
? mockVideos
|
||||
: mockVideos.filter(v => v.type === activeTab);
|
||||
|
||||
const materialCases = [
|
||||
'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=modern%20city%20skyline%20night%20view&image_size=landscape_16_9',
|
||||
'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=nature%20forest%20landscape%20sunlight&image_size=landscape_16_9',
|
||||
'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=abstract%20technology%20background%20digital&image_size=landscape_16_9',
|
||||
'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=food%20cooking%20kitchen%20delicious&image_size=landscape_16_9',
|
||||
'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=fashion%20clothing%20style%20elegant&image_size=landscape_16_9',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="content_box">
|
||||
@@ -172,7 +171,7 @@ const HomePage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
{/* ========== 顶部工作台引导区域(三步流程) ========== */}
|
||||
<div className="animate-fadeInUp" style={{
|
||||
{/* <div className="animate-fadeInUp" style={{
|
||||
padding: '24px 28px',
|
||||
borderRadius: 16,
|
||||
background: 'linear-gradient(135deg, #f0f9ff 0%, #faf5ff 50%, #fef3c7 100%)',
|
||||
@@ -181,7 +180,6 @@ const HomePage: React.FC = () => {
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* 区域标题 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18 }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
@@ -194,19 +192,6 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div >
|
||||
<span style={{
|
||||
cursor: 'pointer',
|
||||
fontSize: 12, color: '#1a50bbff', letterSpacing: 0.3,
|
||||
}}
|
||||
onClick={() => {
|
||||
navigate('/authorization')
|
||||
}}
|
||||
>如需进行账户素材推送 一键推送
|
||||
<ArrowRightOutlined style={{ marginLeft: 8, transform: 'rotate(0deg)' }} />
|
||||
|
||||
</span>
|
||||
</div> */}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -227,7 +212,6 @@ const HomePage: React.FC = () => {
|
||||
<div style={{ textAlign: 'center', fontSize: 20, fontWeight: 700, color: '#1e293b', marginBottom: 14, letterSpacing: 1 }}>
|
||||
第1步
|
||||
</div>
|
||||
{/* 插图占位:项目卡(标题/描述输入框 + 行业分类 chip) */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
minHeight: 140,
|
||||
@@ -240,21 +224,18 @@ const HomePage: React.FC = () => {
|
||||
gap: 8,
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
{/* 项目名称占位 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: 2, background: '#6366f1' }} />
|
||||
<div style={{ flex: 1, height: 22, background: '#fff', border: '1px solid #e2e8f0', borderRadius: 4, display: 'flex', alignItems: 'center', padding: '0 8px', fontSize: 10, color: '#94a3b8' }}>
|
||||
项目名称...
|
||||
</div>
|
||||
</div>
|
||||
{/* 行业分类 chip */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
<div style={{ padding: '2px 8px', background: '#eef2ff', color: '#6366f1', borderRadius: 10, fontSize: 10, fontWeight: 500, border: '1px solid #c7d2fe' }}>美妆</div>
|
||||
<div style={{ padding: '2px 8px', background: '#fff7ed', color: '#f97316', borderRadius: 10, fontSize: 10, fontWeight: 500, border: '1px solid #fed7aa' }}>美食</div>
|
||||
<div style={{ padding: '2px 8px', background: '#ecfdf5', color: '#10b981', borderRadius: 10, fontSize: 10, fontWeight: 500, border: '1px solid #a7f3d0' }}>3C数码</div>
|
||||
<div style={{ padding: '2px 8px', background: '#f5f3ff', color: '#8b5cf6', borderRadius: 10, fontSize: 10, fontWeight: 500, border: '1px solid #ddd6fe' }}>服饰</div>
|
||||
</div>
|
||||
{/* 描述占位行 */}
|
||||
<div style={{ height: 16, background: '#fff', border: '1px solid #e2e8f0', borderRadius: 4 }} />
|
||||
<div style={{ height: 16, width: '70%', background: '#fff', border: '1px solid #e2e8f0', borderRadius: 4 }} />
|
||||
</div>
|
||||
@@ -287,7 +268,6 @@ const HomePage: React.FC = () => {
|
||||
<div style={{ textAlign: 'center', fontSize: 20, fontWeight: 700, color: '#1e293b', marginBottom: 14, letterSpacing: 1 }}>
|
||||
第2步
|
||||
</div>
|
||||
{/* 插图占位:图片/视频切换 + 尺寸/时长参数 */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
minHeight: 140,
|
||||
@@ -300,7 +280,6 @@ const HomePage: React.FC = () => {
|
||||
gap: 8,
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
{/* 图片 / 视频 切换 */}
|
||||
<div style={{ display: 'flex', background: '#f1f5f9', borderRadius: 6, padding: 2, gap: 2 }}>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, padding: '5px 0', background: '#fff', borderRadius: 4, fontSize: 11, fontWeight: 600, color: '#6366f1', boxShadow: '0 1px 3px rgba(99,102,241,0.15)' }}>
|
||||
<VideoCameraOutlined style={{ fontSize: 11 }} />视频
|
||||
@@ -309,7 +288,6 @@ const HomePage: React.FC = () => {
|
||||
<PictureOutlined style={{ fontSize: 11 }} /> 图片
|
||||
</div>
|
||||
</div>
|
||||
{/* 尺寸参数 */}
|
||||
<div>
|
||||
<div style={{ fontSize: 9, color: '#94a3b8', marginBottom: 3 }}>尺寸比例</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
@@ -318,7 +296,6 @@ const HomePage: React.FC = () => {
|
||||
<div style={{ flex: 1, textAlign: 'center', padding: '4px 0', background: '#fff', color: '#64748b', border: '1px solid #e2e8f0', borderRadius: 4, fontSize: 10 }}>1:1</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 时长参数 */}
|
||||
<div>
|
||||
<div style={{ fontSize: 9, color: '#94a3b8', marginBottom: 3 }}>时长</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
@@ -357,7 +334,6 @@ const HomePage: React.FC = () => {
|
||||
<div style={{ textAlign: 'center', fontSize: 20, fontWeight: 700, color: '#1e293b', marginBottom: 14, letterSpacing: 1 }}>
|
||||
第3步
|
||||
</div>
|
||||
{/* 插图占位:核对清单(✓ 项)+ 一键生成按钮 */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
minHeight: 140,
|
||||
@@ -380,12 +356,7 @@ const HomePage: React.FC = () => {
|
||||
<div style={{ width: 14, height: 14, borderRadius: '50%', background: '#10b981', color: '#fff', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700 }}>✓</div>
|
||||
<div style={{ fontSize: 10, color: '#065f46', fontWeight: 500 }}>尺寸 9:16 · 时长 5s</div>
|
||||
</div>
|
||||
{/* <div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 8px', background: '#fff7ed', border: '1px solid #fed7aa', borderRadius: 5 }}>
|
||||
<div style={{ width: 14, height: 14, borderRadius: '50%', background: '#f97316', color: '#fff', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700 }}>!</div>
|
||||
<div style={{ fontSize: 10, color: '#9a3412', fontWeight: 500 }}>参考素材 0/3</div>
|
||||
</div> */}
|
||||
</div>
|
||||
{/* 一键生成按钮 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -450,7 +421,6 @@ const HomePage: React.FC = () => {
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* 高光层(hover 时轻微变亮) */}
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
@@ -466,11 +436,11 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* ========== AI 创作入口区域 ========== */}
|
||||
<div className="animate-fadeInUp stagger-children" style={{
|
||||
padding: '24px 28px',
|
||||
<div className="animate-fadeInUp" style={{
|
||||
padding: '20px',
|
||||
borderRadius: 16,
|
||||
background: '#fff',
|
||||
border: '1px solid #e2e8f0',
|
||||
@@ -480,7 +450,7 @@ const HomePage: React.FC = () => {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 18,
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 4, height: 18, borderRadius: 2,
|
||||
@@ -494,13 +464,13 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
|
||||
{aiEntries.map((entry, index) => {
|
||||
// 三个入口用三种不同色调的渐变光晕作为视觉区分,但都保持白底卡片
|
||||
const accentMap = [
|
||||
{ color: '#6366f1', light: 'rgba(99,102,241,0.10)', tag: '复刻', bg: hot },
|
||||
{ color: '#f97316', light: 'rgba(249,115,22,0.10)', tag: '混剪', bg: mashup },
|
||||
{ color: '#10b981', light: 'rgba(16,185,129,0.10)', tag: '云创', bg: aicreate },
|
||||
{ color: '#3b82f6', light: 'rgba(59,130,246,0.12)', tag: '项目' },
|
||||
{ color: '#6366f1', light: 'rgba(99,102,241,0.12)', tag: '复刻' },
|
||||
{ color: '#f97316', light: 'rgba(249,115,22,0.12)', tag: '拆镜' },
|
||||
{ color: '#10b981', light: 'rgba(16,185,129,0.12)', tag: '云创' },
|
||||
];
|
||||
const accent = accentMap[index] || accentMap[0];
|
||||
return (
|
||||
@@ -509,57 +479,70 @@ const HomePage: React.FC = () => {
|
||||
onClick={() => navigate(entry.path)}
|
||||
className="project-card"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '20px 22px',
|
||||
borderRadius: 14,
|
||||
flex: '1',
|
||||
minWidth: 260,
|
||||
height: 120,
|
||||
padding: '16px',
|
||||
borderRadius: 16,
|
||||
background: '#fff',
|
||||
border: '1px solid #e2e8f0',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transition: 'all 0.3s cubic-bezier(0.4,0,0.2,1)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
backgroundImage: `url(${accent.bg})`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: '100% 100%',
|
||||
backgroundPosition: 'center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = accent.color;
|
||||
e.currentTarget.style.boxShadow = `0 12px 32px ${accent.light}`;
|
||||
e.currentTarget.style.boxShadow = `0 8px 24px ${accent.light}`;
|
||||
e.currentTarget.style.background = accent.light;
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#e2e8f0';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
e.currentTarget.style.background = '#fff';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
{/* 顶部装饰光带 */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0, left: 0, right: 0, height: 3,
|
||||
background: `linear-gradient(90deg, ${accent.color}, ${accent.color}88)`,
|
||||
}} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div style={{
|
||||
<div
|
||||
style={{
|
||||
width: 48, height: 48,
|
||||
borderRadius: 12,
|
||||
background: accent.light,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<span style={{ color: accent.color, fontSize: 22, display: 'flex' }}>{entry.icon}</span>
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.3s ease',
|
||||
color: accent.color,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = accent.color;
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = accent.light;
|
||||
e.currentTarget.style.color = accent.color;
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 20, display: 'flex', transition: 'all 0.3s ease' }}>{entry.icon}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{entry.title}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 10, color: accent.color,
|
||||
padding: '2px 7px', borderRadius: 4,
|
||||
background: accent.light, fontWeight: 600,
|
||||
}}>{accent.tag}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b', }}>
|
||||
{entry.description}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 11, color: accent.color,
|
||||
padding: '2px 8px', borderRadius: 6,
|
||||
background: accent.light, fontWeight: 600,
|
||||
}}>{accent.tag}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: '#1f2937', marginBottom: 4 }}>
|
||||
{entry.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#6b7280', marginBottom: 14, lineHeight: 1.5, minHeight: 36 }}>
|
||||
{entry.description}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
@@ -568,6 +551,7 @@ const HomePage: React.FC = () => {
|
||||
color: accent.color,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{entry.action}
|
||||
<ArrowRightOutlined style={{ fontSize: 12 }} />
|
||||
@@ -578,331 +562,299 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== 近期作品区域 ========== */}
|
||||
{/* ========== 作品与案例区域 ========== */}
|
||||
<div className="animate-fadeInUp" style={{
|
||||
padding: '24px 28px',
|
||||
borderRadius: 16,
|
||||
background: '#fff',
|
||||
border: '1px solid #e2e8f0',
|
||||
marginBottom: 20,
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{/* <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{
|
||||
width: 4, height: 18, borderRadius: 2,
|
||||
background: 'linear-gradient(180deg, #6366f1, #a855f7)',
|
||||
}} />
|
||||
<div style={{ fontSize: 17, fontWeight: 700, color: '#1f2937', letterSpacing: 0.3 }}>
|
||||
近期作品
|
||||
{activeContentTab === 'works' ? '近期作品' : '素材案例'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/* Tab切换 */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={handleTabChange}
|
||||
items={tabs.map(tab => ({
|
||||
key: tab.key,
|
||||
label: tab.label,
|
||||
}))}
|
||||
className="homepage-tabs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 视频网格 */}
|
||||
<div className="stagger-children" style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 16 }}>
|
||||
{filteredVideos.length === 0 ? (
|
||||
<div style={{
|
||||
gridColumn: '1 / -1',
|
||||
padding: '60px 0',
|
||||
textAlign: 'center',
|
||||
color: '#94a3b8',
|
||||
fontSize: 14,
|
||||
}}>
|
||||
<PictureOutlined style={{ fontSize: 36, color: '#cbd5e1', marginBottom: 8 }} />
|
||||
<div>暂无作品</div>
|
||||
</div>
|
||||
) : filteredVideos.map((video) => (
|
||||
<div
|
||||
key={video.id || `${video.type}-${Math.random()}`}
|
||||
className="project-card"
|
||||
onClick={() => {
|
||||
// 按模块分发跳转:
|
||||
// - 爆款复刻(hot)→ 复刻详情页
|
||||
// - AI 成片(ai)→ 对话/生成页
|
||||
// - 项目记录(project)→ 项目详情页
|
||||
const id = video.moduleProjectId;
|
||||
if (video.type === 'hotOpeningReplicate' && id != null) {
|
||||
navigate(`/initial/${id}/initialinfo`);
|
||||
} else if (video.type === 'shotReplicate' && id != null) {
|
||||
navigate(`/removelens/${id}/removefenbu`);
|
||||
} else if (video.type === 'chatAi') {
|
||||
navigate(`/conversation`);
|
||||
} else if (video.type === 'project') {
|
||||
navigate(`/project`);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
background: '#fff',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
aspectRatio: '16/9',
|
||||
// background: '#1a1a2e',
|
||||
}}>
|
||||
{(() => {
|
||||
// 1) 读取后端 API 基础地址;环境变量未配置时降级到本地 8000
|
||||
const apiBase = (import.meta.env.VITE_API_BASE as string) || 'http://localhost:8000';
|
||||
|
||||
// 2) 判断当前作品是否为"图片":
|
||||
// - 爆款复刻(type === 'hot')始终是视频,不参与图片判断
|
||||
// - 其他模块(项目记录 / AI 成片)根据 genType 判定
|
||||
// - genType 可能是字符串 'image',也可能是数字 1(兼容两种后端约定)
|
||||
const isImage = video.type !== 'hotOpeningReplicate'
|
||||
&& (
|
||||
String(video.resourceType ?? '').toLowerCase() === 'image'
|
||||
|| video.resourceType === 1
|
||||
|| String(video.resourceType ?? '') === '1'
|
||||
);
|
||||
|
||||
// 3) 根据媒体类型选择对应的资源路径:
|
||||
// - 图片:后端返回的 imageUrl 已经是带签名的完整相对路径
|
||||
// 形如 /static/generate/images/2026/06/26/0019f017f5924df4123.png?exp=...&sign=...&w=300&p=50
|
||||
// - 视频:使用视频封面 videoCoverUrl(这是视频作品的静态缩略图)
|
||||
// - 爆款复刻(type === 'hot')特殊处理:使用 finalVideoCoverUrl
|
||||
let rawPath = '';
|
||||
if (isImage) {
|
||||
rawPath = '/static' + video.resultUrl + '&w=300&p=50' || '';
|
||||
} else if (video.type === 'hot') {
|
||||
rawPath = video.coverUrl || video.resultUrl || video.resultUrl || '';
|
||||
} else {
|
||||
rawPath = video.coverUrl || video.resultUrl || video.resultUrl || '';
|
||||
}
|
||||
|
||||
// 4) 拼装最终 src:
|
||||
// - rawPath 为空 → 用空串(让 <img> 走 onError 兜底)
|
||||
// - 已经是 http(s) 完整 URL → 直接使用(OSS / CDN 场景)
|
||||
// - 否则视为后端相对路径,前面拼 apiBase
|
||||
const src = rawPath
|
||||
? (rawPath.startsWith('http') ? rawPath : apiBase + rawPath)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={video.title || video.name || '作品'}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{(() => {
|
||||
const isImage = String(video.resourceType ?? '').toLowerCase() === 'image';
|
||||
if (isImage) return null;
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.2)',
|
||||
}}>
|
||||
<VideoCameraOutlined style={{ fontSize: 28, color: '#fff' }} />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
background: '#f8fafc',
|
||||
paddingTop: 0,
|
||||
}}>
|
||||
<div style={{
|
||||
marginTop: 6,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
color: '#64748b',
|
||||
}}>
|
||||
{(() => {
|
||||
// 显示所属模块,而非媒体类型
|
||||
const moduleMap: Record<string, string> = {
|
||||
project: '项目媒体',
|
||||
chatAi: 'AI成片',
|
||||
hotOpeningReplicate: '爆款复刻',
|
||||
shotReplicate: '拆镜复刻',
|
||||
};
|
||||
const moduleLabel = moduleMap[video.type] || '其他';
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '1px 6px',
|
||||
border: '1px solid #3b82f6',
|
||||
borderRadius: 4,
|
||||
color: '#3b82f6',
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
background: '#fff',
|
||||
lineHeight: 1.4,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{moduleLabel}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
<span style={{ color: '#94a3b8' }}>·</span>
|
||||
<span style={{ whiteSpace: 'nowrap' }}>{formatShortDate(video.generatedTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
{/* ========== 素材案例区域 ========== */}
|
||||
{caseAssets.length > 0 && (
|
||||
<div className="animate-fadeInUp" style={{
|
||||
padding: '24px 28px',
|
||||
borderRadius: 16,
|
||||
background: '#fff',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 18,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{
|
||||
width: 4, height: 18, borderRadius: 2,
|
||||
background: 'linear-gradient(180deg, #6366f1, #a855f7)',
|
||||
}} />
|
||||
<div style={{ fontSize: 17, fontWeight: 700, color: '#1f2937', letterSpacing: 0.3 }}>
|
||||
素材案例
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#94a3b8', marginLeft: 4 }}>
|
||||
精选优质作品参考
|
||||
</div>
|
||||
</div>
|
||||
{/* <div style={{
|
||||
fontSize: 13, color: '#6366f1', cursor: 'pointer', fontWeight: 500,
|
||||
display: 'flex', alignItems: 'center', gap: 2,
|
||||
}}>
|
||||
更多案例
|
||||
<ArrowRightOutlined style={{ fontSize: 11 }} />
|
||||
</div> */}
|
||||
|
||||
{/* 外层Tab切换:近期作品 / 素材案例 */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{[
|
||||
{ key: 'works', label: '近期作品' },
|
||||
{ key: 'cases', label: '素材案例' },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => setActiveContentTab(item.key as 'works' | 'cases')}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.25s ease',
|
||||
background: activeContentTab === item.key
|
||||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||||
: '#f1f5f9',
|
||||
color: activeContentTab === item.key ? '#fff' : '#64748b',
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab切换 */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Tabs
|
||||
activeKey={activeCaseTab}
|
||||
onChange={(key) => {
|
||||
setActiveCaseTab(key);
|
||||
getHomeCaseButton(key).then((btnRes: any) => {
|
||||
if (btnRes?.categories?.[0]?.assets) {
|
||||
setCaseAssets(btnRes.categories[0].assets);
|
||||
} else {
|
||||
setCaseAssets([]);
|
||||
}
|
||||
});
|
||||
}}
|
||||
items={caseHeader.map((item: any) => ({
|
||||
key: item.id,
|
||||
label: item.name,
|
||||
}))}
|
||||
className="homepage-tabs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ========== 素材案例列表(与近期作品一致) ========== */}
|
||||
<div className="stagger-children" style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 16 }}>
|
||||
{caseAssets.length === 0 ? (
|
||||
<div style={{
|
||||
gridColumn: '1 / -1',
|
||||
padding: '60px 0',
|
||||
textAlign: 'center',
|
||||
color: '#94a3b8',
|
||||
fontSize: 14,
|
||||
}}>
|
||||
<PictureOutlined style={{ fontSize: 36, color: '#cbd5e1', marginBottom: 8 }} />
|
||||
<div>暂无素材</div>
|
||||
{/* 内容区域 */}
|
||||
{activeContentTab === 'works' ? (
|
||||
<>
|
||||
{/* 近期作品子Tab */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={handleTabChange}
|
||||
items={tabs.map(tab => ({
|
||||
key: tab.key,
|
||||
label: tab.label,
|
||||
}))}
|
||||
className="homepage-tabs"
|
||||
/>
|
||||
</div>
|
||||
) : caseAssets.map((asset: any, index: number) => (
|
||||
<div
|
||||
key={asset.id || index}
|
||||
className="project-card"
|
||||
onClick={() => setPreviewAsset(asset)}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
background: '#fff',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
{/* 媒体区域 16:9 */}
|
||||
<div style={{ position: 'relative', aspectRatio: '16/9', }}>
|
||||
{asset.mediaType === 'video' ? (
|
||||
<>
|
||||
<video
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${asset.url}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
|
||||
{/* 近期作品网格 */}
|
||||
<div className="stagger-children" style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 16 }}>
|
||||
{filteredVideos.length === 0 ? (
|
||||
<div style={{
|
||||
gridColumn: '1 / -1',
|
||||
padding: '60px 0',
|
||||
textAlign: 'center',
|
||||
color: '#94a3b8',
|
||||
fontSize: 14,
|
||||
}}>
|
||||
<PictureOutlined style={{ fontSize: 36, color: '#cbd5e1', marginBottom: 8 }} />
|
||||
<div>暂无作品</div>
|
||||
</div>
|
||||
) : filteredVideos.map((video) => (
|
||||
<div
|
||||
key={video.id || `${video.type}-${Math.random()}`}
|
||||
className="project-card"
|
||||
onClick={() => {
|
||||
const id = video.moduleProjectId;
|
||||
if (video.type === 'hotOpeningReplicate' && id != null) {
|
||||
navigate(`/initial/${id}/initialinfo`);
|
||||
} else if (video.type === 'shotReplicate' && id != null) {
|
||||
navigate(`/removelens/${id}/removefenbu`);
|
||||
} else if (video.type === 'chatAi') {
|
||||
navigate(`/conversation`);
|
||||
} else if (video.type === 'project') {
|
||||
navigate(`/project`);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
background: '#fff',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
aspectRatio: '16/9',
|
||||
}}>
|
||||
{(() => {
|
||||
const apiBase = (import.meta.env.VITE_API_BASE as string) || 'http://localhost:8000';
|
||||
const isImage = video.type !== 'hotOpeningReplicate'
|
||||
&& (
|
||||
String(video.resourceType ?? '').toLowerCase() === 'image'
|
||||
|| video.resourceType === 1
|
||||
|| String(video.resourceType ?? '') === '1'
|
||||
);
|
||||
let rawPath = '';
|
||||
if (isImage) {
|
||||
rawPath = '/static' + video.resultUrl + '&w=300&p=50' || '';
|
||||
} else if (video.type === 'hot') {
|
||||
rawPath = video.coverUrl || video.resultUrl || video.resultUrl || '';
|
||||
} else {
|
||||
rawPath = video.coverUrl || video.resultUrl || video.resultUrl || '';
|
||||
}
|
||||
const src = rawPath
|
||||
? (rawPath.startsWith('http') ? rawPath : apiBase + rawPath)
|
||||
: '';
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={video.title || video.name || '作品'}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{(() => {
|
||||
const isImage = String(video.resourceType ?? '').toLowerCase() === 'image';
|
||||
if (isImage) return null;
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.2)',
|
||||
}}>
|
||||
<VideoCameraOutlined style={{ fontSize: 28, color: '#fff' }} />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
background: '#f8fafc',
|
||||
paddingTop: 0,
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
marginTop: 6,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.2)',
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
color: '#64748b',
|
||||
}}>
|
||||
<VideoCameraOutlined style={{ fontSize: 28, color: '#fff' }} />
|
||||
{(() => {
|
||||
const moduleMap: Record<string, string> = {
|
||||
project: '项目媒体',
|
||||
chatAi: 'AI成片',
|
||||
hotOpeningReplicate: '爆款复刻',
|
||||
shotReplicate: '拆镜复刻',
|
||||
};
|
||||
const moduleLabel = moduleMap[video.type] || '其他';
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '1px 6px',
|
||||
border: '1px solid #3b82f6',
|
||||
borderRadius: 4,
|
||||
color: '#3b82f6',
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
background: '#fff',
|
||||
lineHeight: 1.4,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{moduleLabel}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
<span style={{ color: '#94a3b8' }}>·</span>
|
||||
<span style={{ whiteSpace: 'nowrap' }}>{formatShortDate(video.generatedTime)}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${asset.url}`}
|
||||
alt={asset.title || `素材 ${index + 1}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 底部信息栏 */}
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
background: '#f8fafc',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: '#64748b',
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{asset.title || `素材 ${index + 1}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 素材案例子Tab */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Tabs
|
||||
activeKey={activeCaseTab}
|
||||
onChange={(key) => {
|
||||
setActiveCaseTab(key);
|
||||
getHomeCaseButton(key).then((btnRes: any) => {
|
||||
if (btnRes?.categories?.[0]?.assets) {
|
||||
setCaseAssets(btnRes.categories[0].assets);
|
||||
} else {
|
||||
setCaseAssets([]);
|
||||
}
|
||||
});
|
||||
}}
|
||||
items={caseHeader.map((item: any) => ({
|
||||
key: item.id,
|
||||
label: item.name,
|
||||
}))}
|
||||
className="homepage-tabs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 素材案例网格 */}
|
||||
<div className="stagger-children" style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 16 }}>
|
||||
{caseAssets.length === 0 ? (
|
||||
<div style={{
|
||||
gridColumn: '1 / -1',
|
||||
padding: '60px 0',
|
||||
textAlign: 'center',
|
||||
color: '#94a3b8',
|
||||
fontSize: 14,
|
||||
}}>
|
||||
<PictureOutlined style={{ fontSize: 36, color: '#cbd5e1', marginBottom: 8 }} />
|
||||
<div>暂无素材</div>
|
||||
</div>
|
||||
) : caseAssets.map((asset: any, index: number) => (
|
||||
<div
|
||||
key={asset.id || index}
|
||||
className="project-card"
|
||||
onClick={() => setPreviewAsset(asset)}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
background: '#fff',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative', aspectRatio: '16/9', }}>
|
||||
{asset.mediaType === 'video' ? (
|
||||
<>
|
||||
<video
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${asset.url}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.2)',
|
||||
}}>
|
||||
<VideoCameraOutlined style={{ fontSize: 28, color: '#fff' }} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${asset.url}`}
|
||||
alt={asset.title || `素材 ${index + 1}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
background: '#f8fafc',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: '#64748b',
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{asset.title || `素材 ${index + 1}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== 预览弹窗 ========== */}
|
||||
<Modal
|
||||
|
||||
@@ -449,7 +449,10 @@ const GenerateConver: React.FC = () => {
|
||||
border: '1px solid rgba(99, 102, 241, 0.08)',
|
||||
position: 'relative', overflow: 'hidden', flexWrap: 'wrap', gap: 12,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 ,
|
||||
paddingBottom: 12,
|
||||
|
||||
}}>
|
||||
{/* <div style={{ width: 32, height: 2, background: 'linear-gradient(90deg, transparent, #6366f1, #8b5cf6, transparent)', borderRadius: 1 }} /> */}
|
||||
<div>
|
||||
<h2 style={{
|
||||
|
||||
@@ -90,9 +90,6 @@ const JoinTeamPage: React.FC = () => {
|
||||
extra={
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => navigate('/projects')}>返回首页</Button>
|
||||
{user && (
|
||||
<Button onClick={() => navigate('/team-management')}>团队管理</Button>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
@@ -140,7 +137,6 @@ const JoinTeamPage: React.FC = () => {
|
||||
extra={
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => navigate('/projects')}>返回首页</Button>
|
||||
<Button onClick={() => navigate('/team-management')}>团队管理</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -295,9 +295,8 @@
|
||||
|
||||
.login-code-btn {
|
||||
height: 48px !important;
|
||||
border-radius: 0 10px 10px 0 !important;
|
||||
border-radius: 10 !important;
|
||||
border: 1.5px solid #e2e8f0 !important;
|
||||
border-left: none !important;
|
||||
font-weight: 600 !important;
|
||||
min-width: 100px !important;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Table, Tag, Input, Pagination, Typography, Select, App, Modal } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FolderOpenOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { FolderOpenOutlined, EyeOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import { getResourcesMaterialList, getPreTestList, submitPreTest, getDefaultPreTest } from '../api';
|
||||
import PreResultDisplay from '../components/PreResultDisplay';
|
||||
|
||||
@@ -436,6 +436,19 @@ const MaterialListPage: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
onClick={() => navigate('/materials/private-portrait-virtual')}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
fontSize: 14,
|
||||
borderColor: '#8b5cf6',
|
||||
color: '#7c3aed',
|
||||
background: '#f5f3ff',
|
||||
}}
|
||||
>
|
||||
私域虚拟人像库
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={pushTemplatesLoading}
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Upload,
|
||||
} from 'antd';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CloudSyncOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
PictureOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
UploadOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
createPrivatePortraitVirtualAsset,
|
||||
createPrivatePortraitVirtualProject,
|
||||
deletePrivatePortraitVirtualAsset,
|
||||
deletePrivatePortraitVirtualProject,
|
||||
getPrivatePortraitVirtualAssets,
|
||||
getPrivatePortraitVirtualConfig,
|
||||
getPrivatePortraitVirtualProjects,
|
||||
syncPrivatePortraitVirtualAsset,
|
||||
uploadImage,
|
||||
uploadVideo,
|
||||
} from '../api';
|
||||
import type { PrivatePortraitAsset, PrivatePortraitConfig, PrivatePortraitProject } from '../types';
|
||||
|
||||
const { Text, Title, Paragraph } = Typography;
|
||||
|
||||
type AssetTypeFilter = 'Image' | 'Video' | undefined;
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||
creating: { label: '本地创建中', color: 'processing' },
|
||||
Processing: { label: '火山处理中', color: 'processing' },
|
||||
Active: { label: '可用于生成', color: 'success' },
|
||||
Failed: { label: '入库失败', color: 'error' },
|
||||
local_deleted: { label: '本地已删', color: 'default' },
|
||||
remote_deleted: { label: '远端已删', color: 'default' },
|
||||
delete_failed: { label: '远端删除失败', color: 'error' },
|
||||
};
|
||||
|
||||
const assetTypeConfig: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
|
||||
Image: { label: '图片', color: 'green', icon: <PictureOutlined /> },
|
||||
Video: { label: '视频', color: 'blue', icon: <VideoCameraOutlined /> },
|
||||
};
|
||||
|
||||
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) => {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:') || url.startsWith('blob:')) return url;
|
||||
const base = (import.meta.env.VITE_API_BASE || 'http://localhost:8000').replace(/\/$/, '');
|
||||
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
};
|
||||
|
||||
const getAssetPreviewUrl = (asset: PrivatePortraitAsset) => {
|
||||
return buildPreviewUrl(asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.remoteUrl || asset.sourceUrl);
|
||||
};
|
||||
|
||||
const guessAssetType = (file?: File | null): 'Image' | 'Video' => {
|
||||
if (!file) return 'Image';
|
||||
if (file.type.startsWith('video/')) return 'Video';
|
||||
const name = file.name.toLowerCase();
|
||||
if (/\.(mp4|mov|webm|m4v|avi|mkv)$/.test(name)) return 'Video';
|
||||
return 'Image';
|
||||
};
|
||||
|
||||
const getVideoDuration = (file: File): Promise<number | null> => {
|
||||
return new Promise((resolve) => {
|
||||
if (!file.type.startsWith('video/')) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.onloadedmetadata = () => {
|
||||
const duration = Number.isFinite(video.duration) ? video.duration : null;
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(duration);
|
||||
};
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(null);
|
||||
};
|
||||
video.src = url;
|
||||
});
|
||||
};
|
||||
|
||||
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
|
||||
const value = status || '-';
|
||||
const config = statusConfig[value];
|
||||
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 PrivatePortraitVirtualMaterialPage: React.FC = () => {
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const [config, setConfig] = useState<PrivatePortraitConfig | null>(null);
|
||||
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>();
|
||||
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
|
||||
const [projectLoading, setProjectLoading] = useState(false);
|
||||
const [assetLoading, setAssetLoading] = useState(false);
|
||||
const [assetPage, setAssetPage] = useState(1);
|
||||
const [assetPageSize, setAssetPageSize] = useState(20);
|
||||
const [assetTotal, setAssetTotal] = useState(0);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [assetStatus, setAssetStatus] = useState<string>();
|
||||
const [assetType, setAssetType] = useState<AssetTypeFilter>();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [creatingProject, setCreatingProject] = useState(false);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [assetName, setAssetName] = useState('');
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState('');
|
||||
const [previewType, setPreviewType] = useState<'Image' | 'Video'>('Image');
|
||||
const [createForm] = Form.useForm<{ name: string; description?: string }>();
|
||||
|
||||
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 loadConfig = async () => {
|
||||
try {
|
||||
const next = await getPrivatePortraitVirtualConfig();
|
||||
setConfig(next);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载私域素材额度失败');
|
||||
}
|
||||
};
|
||||
|
||||
const loadProjects = async () => {
|
||||
setProjectLoading(true);
|
||||
try {
|
||||
const res = await getPrivatePortraitVirtualProjects({ page: 1, pageSize: 100, status: 'active' });
|
||||
const next = res.items || [];
|
||||
setProjects(next);
|
||||
setSelectedProjectId((prev) => prev && next.some((item) => item.id === prev) ? prev : next[0]?.id);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载虚拟人像项目组失败');
|
||||
} finally {
|
||||
setProjectLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAssets = async (page = assetPage, pageSize = assetPageSize) => {
|
||||
if (!selectedProjectId) {
|
||||
setAssets([]);
|
||||
setAssetTotal(0);
|
||||
return;
|
||||
}
|
||||
setAssetLoading(true);
|
||||
try {
|
||||
const res = await getPrivatePortraitVirtualAssets(selectedProjectId, {
|
||||
page,
|
||||
pageSize,
|
||||
keyword: keyword.trim() || undefined,
|
||||
status: assetStatus,
|
||||
assetType,
|
||||
});
|
||||
setAssets(res.items || []);
|
||||
setAssetTotal(res.total || 0);
|
||||
setAssetPage(page);
|
||||
setAssetPageSize(pageSize);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载虚拟人像素材失败');
|
||||
} finally {
|
||||
setAssetLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reloadAll = async () => {
|
||||
await Promise.all([loadConfig(), loadProjects()]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reloadAll();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProjectId) loadAssets(1, assetPageSize);
|
||||
}, [selectedProjectId]);
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
const values = await createForm.validateFields();
|
||||
setCreatingProject(true);
|
||||
try {
|
||||
const project = await createPrivatePortraitVirtualProject(values);
|
||||
message.success('虚拟人像项目组已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
await loadProjects();
|
||||
setSelectedProjectId(project.id);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '创建项目组失败');
|
||||
} finally {
|
||||
setCreatingProject(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedProjectId) {
|
||||
message.warning('请先创建或选择项目组');
|
||||
return;
|
||||
}
|
||||
const file = fileList[0]?.originFileObj as File | undefined;
|
||||
if (!file) {
|
||||
message.warning('请先选择图片或视频素材');
|
||||
return;
|
||||
}
|
||||
const currentType = guessAssetType(file);
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded = currentType === 'Video' ? await uploadVideo(file) : await uploadImage(file);
|
||||
const duration = currentType === 'Video' ? await getVideoDuration(file) : null;
|
||||
await createPrivatePortraitVirtualAsset(selectedProjectId, {
|
||||
url: uploaded.url,
|
||||
assetType: currentType,
|
||||
name: assetName.trim() || file.name,
|
||||
videoDuration: duration,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type || null,
|
||||
});
|
||||
message.success(currentType === 'Video' ? '视频素材已提交入库,处理中' : '图片素材已提交入库,处理中');
|
||||
setUploadOpen(false);
|
||||
setFileList([]);
|
||||
setAssetName('');
|
||||
await Promise.all([loadConfig(), loadProjects(), loadAssets(1, assetPageSize)]);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '上传素材失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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) => {
|
||||
try {
|
||||
await deletePrivatePortraitVirtualAsset(assetId);
|
||||
message.success('素材已删除,远端删除将异步执行');
|
||||
await Promise.all([loadConfig(), loadProjects(), loadAssets(assetPage, assetPageSize)]);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '删除素材失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
if (!selectedProjectId) return;
|
||||
try {
|
||||
await deletePrivatePortraitVirtualProject(selectedProjectId);
|
||||
message.success('项目组已删除,远端资产组将异步删除');
|
||||
setSelectedProjectId(undefined);
|
||||
await reloadAll();
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '删除项目组失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openPreview = (asset: PrivatePortraitAsset) => {
|
||||
const url = getAssetPreviewUrl(asset);
|
||||
if (!url) {
|
||||
message.warning('暂无可预览地址');
|
||||
return;
|
||||
}
|
||||
setPreviewUrl(url);
|
||||
setPreviewType(asset.assetType === 'Video' ? 'Video' : 'Image');
|
||||
setPreviewOpen(true);
|
||||
};
|
||||
|
||||
const renderAssetCard = (asset: PrivatePortraitAsset) => {
|
||||
const preview = getAssetPreviewUrl(asset);
|
||||
const isVideo = asset.assetType === 'Video';
|
||||
return (
|
||||
<Card
|
||||
key={asset.id}
|
||||
hoverable
|
||||
bodyStyle={{ padding: 12 }}
|
||||
style={{ borderRadius: 16, overflow: 'hidden', borderColor: '#eef2f7' }}
|
||||
cover={(
|
||||
<div style={{ height: 170, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
|
||||
{preview && !isVideo ? (
|
||||
<img src={preview} alt={asset.name || '虚拟人像素材'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : preview && isVideo && asset.videoCoverUrl ? (
|
||||
<img src={preview} alt={asset.name || '视频封面'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : 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>}
|
||||
<Button size="small" shape="circle" icon={<EyeOutlined />} style={{ position: 'absolute', right: 10, top: 10 }} onClick={() => openPreview(asset)} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Tooltip title={asset.name || asset.remoteAssetId || asset.id}>
|
||||
<Text strong ellipsis style={{ display: 'block' }}>{asset.name || asset.remoteAssetId || '未命名素材'}</Text>
|
||||
</Tooltip>
|
||||
<Space wrap size={4}>
|
||||
<TypeTag type={asset.assetType} />
|
||||
<StatusTag status={asset.status} />
|
||||
</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>
|
||||
<Button size="small" icon={<CloudSyncOutlined />} onClick={() => handleSyncAsset(asset.id)}>同步</Button>
|
||||
<Popconfirm title="确认删除这个虚拟人像素材吗?" onConfirm={() => handleDeleteAsset(asset.id)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '94vh' }}>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/materials')}>返回素材云</Button>
|
||||
<Title level={4} style={{ margin: 0 }}>私域虚拟人像素材库</Title>
|
||||
</Space>
|
||||
|
||||
<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]}>
|
||||
<Col xs={24} lg={7}>
|
||||
<Card
|
||||
title="虚拟人像项目组"
|
||||
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>创建项目组</Button>}
|
||||
style={{ borderRadius: 16, minHeight: 520 }}
|
||||
>
|
||||
<Spin spinning={projectLoading}>
|
||||
{projects.length === 0 ? (
|
||||
<Empty description="暂无虚拟人像项目组" />
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={10}>
|
||||
{projects.map((project) => {
|
||||
const active = selectedProjectId === project.id;
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
onClick={() => setSelectedProjectId(project.id)}
|
||||
style={{
|
||||
padding: 14,
|
||||
borderRadius: 14,
|
||||
cursor: 'pointer',
|
||||
border: active ? '1px solid #8b5cf6' : '1px solid #e2e8f0',
|
||||
background: active ? '#f5f3ff' : '#fff',
|
||||
}}
|
||||
>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }} align="start">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text strong ellipsis style={{ display: 'block' }}>{project.name}</Text>
|
||||
{project.description && <Text type="secondary" ellipsis style={{ display: 'block', fontSize: 12 }}>{project.description}</Text>}
|
||||
</div>
|
||||
<StatusTag status={project.status} />
|
||||
</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>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={17}>
|
||||
<Card
|
||||
title={selectedProject ? selectedProject.name : '素材资产'}
|
||||
extra={(
|
||||
<Space wrap>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(assetPage, assetPageSize); }} loading={assetLoading}>刷新</Button>
|
||||
<Button type="primary" icon={<UploadOutlined />} disabled={!selectedProjectId} onClick={() => setUploadOpen(true)}>上传图片/视频</Button>
|
||||
{selectedProjectId && (
|
||||
<Popconfirm title="确认删除当前虚拟人像项目组吗?" onConfirm={handleDeleteProject}>
|
||||
<Button danger icon={<DeleteOutlined />}>删除项目组</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
style={{ borderRadius: 16, minHeight: 520 }}
|
||||
>
|
||||
<Space style={{ width: '100%', marginBottom: 16 }} wrap>
|
||||
<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={assetLoading}>
|
||||
{!selectedProjectId ? (
|
||||
<Empty description="请先创建或选择项目组" style={{ marginTop: 80 }} />
|
||||
) : assets.length === 0 ? (
|
||||
<Empty description="暂无素材,上传图片/视频后会异步入库" style={{ marginTop: 80 }} />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 14 }}>
|
||||
{assets.map(renderAssetCard)}
|
||||
</div>
|
||||
<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>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Modal
|
||||
title="创建虚拟人像项目组"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={handleCreateProject}
|
||||
confirmLoading={creatingProject}
|
||||
okText="创建并同步火山 Asset Group"
|
||||
>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="name" label="项目组名称" rules={[{ required: true, message: '请输入项目组名称' }]}>
|
||||
<Input placeholder="例如:虚拟主播A / 品牌代言人B" maxLength={128} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea placeholder="可填写人物设定、服装风格、素材要求等" maxLength={2000} rows={4} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="上传虚拟人像素材"
|
||||
open={uploadOpen}
|
||||
onCancel={() => setUploadOpen(false)}
|
||||
onOk={handleUpload}
|
||||
confirmLoading={uploading}
|
||||
okText="提交入库"
|
||||
>
|
||||
<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)}
|
||||
listType="picture"
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>选择图片或视频</Button>
|
||||
</Upload>
|
||||
<div style={{ padding: 12, background: '#f8fafc', borderRadius: 12, color: '#64748b', fontSize: 13 }}>
|
||||
当前开放图片和视频,音频暂不接入。提交后会调用火山 CreateAsset 异步处理,状态变为 Active 后才可用于 AI 创作。
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<Modal title="素材预览" open={previewOpen} onCancel={() => setPreviewOpen(false)} footer={null} width={760} destroyOnClose>
|
||||
<div style={{ minHeight: 420, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0f172a', borderRadius: 12, overflow: 'hidden' }}>
|
||||
{previewType === 'Video' ? (
|
||||
<video src={previewUrl} controls autoPlay style={{ maxWidth: '100%', maxHeight: 520 }} />
|
||||
) : (
|
||||
<img src={previewUrl} alt="素材预览" style={{ maxWidth: '100%', maxHeight: 520, objectFit: 'contain' }} />
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivatePortraitVirtualMaterialPage;
|
||||
@@ -638,7 +638,7 @@ function RemoveInfo() {
|
||||
<video
|
||||
controls
|
||||
src={videoUrl}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
style={{ width: '100%', height: '100%'}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -257,14 +257,23 @@ export interface AdminNotification {
|
||||
|
||||
export interface PrivatePortraitConfig {
|
||||
enabled: boolean;
|
||||
imageLimit: number;
|
||||
usedImageCount: number;
|
||||
remainingImageCount: number;
|
||||
assetLimit: number;
|
||||
usedAssetCount: number;
|
||||
remainingAssetCount: number;
|
||||
supportedAssetTypes?: string[];
|
||||
unsupportedAssetTypes?: string[];
|
||||
imageLimit?: number;
|
||||
usedImageCount?: number;
|
||||
remainingImageCount?: number;
|
||||
}
|
||||
|
||||
export type PrivatePortraitLibraryType = 'real_person' | 'aigc_virtual';
|
||||
export type PrivatePortraitAssetType = 'Image' | 'Video' | 'Audio';
|
||||
|
||||
export interface PrivatePortraitProject {
|
||||
id: string;
|
||||
userId?: string | null;
|
||||
libraryType?: PrivatePortraitLibraryType | string;
|
||||
name: string;
|
||||
nameSlug?: string | null;
|
||||
remoteProjectName?: string | null;
|
||||
@@ -272,7 +281,11 @@ export interface PrivatePortraitProject {
|
||||
status: string;
|
||||
assetGroupCount: number;
|
||||
assetCount: number;
|
||||
imageAssetCount?: number;
|
||||
videoAssetCount?: number;
|
||||
activeAssetCount: number;
|
||||
activeImageAssetCount?: number;
|
||||
activeVideoAssetCount?: number;
|
||||
lastUsedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
@@ -301,7 +314,6 @@ export interface PrivatePortraitValidateSession {
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
|
||||
export interface PrivatePortraitProjectCreateWithValidateOut {
|
||||
project: PrivatePortraitProject;
|
||||
validateSession: PrivatePortraitValidateSession;
|
||||
@@ -314,17 +326,30 @@ export interface PrivatePortraitAsset {
|
||||
projectId: string;
|
||||
projectName?: string | null;
|
||||
groupId: string;
|
||||
libraryType?: PrivatePortraitLibraryType | string;
|
||||
remoteGroupId: string;
|
||||
remoteAssetId?: string | null;
|
||||
remoteProjectName?: string | null;
|
||||
assetType: string;
|
||||
assetType: PrivatePortraitAssetType | string;
|
||||
name?: string | null;
|
||||
sourceUrl: string;
|
||||
previewUrl?: string | null;
|
||||
displayUrl?: string | null;
|
||||
providerUrl?: string | null;
|
||||
remoteUrl?: string | null;
|
||||
remoteUrlExpiredAt?: string | null;
|
||||
videoDuration?: number | null;
|
||||
videoCoverUrl?: string | null;
|
||||
fileSize?: number | null;
|
||||
mimeType?: string | null;
|
||||
status: string;
|
||||
moderation?: unknown;
|
||||
lastPollAt?: string | null;
|
||||
nextPollAt?: string | null;
|
||||
pollCount: number;
|
||||
remoteDeleteStatus: string;
|
||||
remoteDeletedAt?: string | null;
|
||||
remoteDeleteError?: string | null;
|
||||
errorMessage?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
@@ -341,9 +366,14 @@ export interface PrivatePortraitSelectableAsset {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
libraryType?: PrivatePortraitLibraryType | string;
|
||||
name?: string | null;
|
||||
assetType: string;
|
||||
assetType: PrivatePortraitAssetType | string;
|
||||
previewUrl?: string | null;
|
||||
displayUrl?: string | null;
|
||||
providerUrl?: string | null;
|
||||
videoDuration?: number | null;
|
||||
videoCoverUrl?: string | null;
|
||||
status: string;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
@@ -354,3 +384,4 @@ export interface PrivatePortraitSelectableAssetListOut {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user