Merge branch 'main' of https://gitee.com/wg123/video-gen
This commit is contained in:
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/index-kx3oQI_t.js"></script>
|
<script type="module" crossorigin src="/assets/index-_9WDiveb.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -41,25 +41,35 @@ function parseSizes(val: unknown): Record<string, Record<string, string>> {
|
|||||||
|
|
||||||
// Default size options with pixel mappings
|
// Default size options with pixel mappings
|
||||||
const SIZE_OPTIONS: Record<string, Record<string, string>> = {
|
const SIZE_OPTIONS: Record<string, Record<string, string>> = {
|
||||||
|
"1K": {
|
||||||
|
"1:1": "1024x1024",
|
||||||
|
"4:3": "1152x864",
|
||||||
|
"3:4": "864x1152",
|
||||||
|
"16:9": "1312x736",
|
||||||
|
"9:16": "736x1312",
|
||||||
|
"3:2": "1248x832",
|
||||||
|
"2:3": "832x1248",
|
||||||
|
"21:9": "1568x672",
|
||||||
|
},
|
||||||
"2K": {
|
"2K": {
|
||||||
"1:1": "2048×2048",
|
"1:1": "2048x2048",
|
||||||
"4:3": "2304×1728",
|
"4:3": "2304x1728",
|
||||||
"3:4": "1728×2304",
|
"3:4": "1728x2304",
|
||||||
"16:9": "2560×1440",
|
"16:9": "2848x1600",
|
||||||
"9:16": "1600×2848",
|
"9:16": "1600x2848",
|
||||||
"3:2": "2496×1664",
|
"3:2": "2496x1664",
|
||||||
"2:3": "1664×2496",
|
"2:3": "1664x2496",
|
||||||
"21:9": "3024×1296",
|
"21:9": "3136x1344",
|
||||||
},
|
},
|
||||||
"4K": {
|
"4K": {
|
||||||
"1:1": "4096×4096",
|
"1:1": "4096x4096",
|
||||||
"4:3": "4608×3456",
|
"4:3": "4704x3520",
|
||||||
"3:4": "3520×4704",
|
"3:4": "3520x4704",
|
||||||
"16:9": "5404×3040",
|
"16:9": "5504x3040",
|
||||||
"9:16": "3040×5504",
|
"9:16": "3040x5504",
|
||||||
"3:2": "4992×3328",
|
"3:2": "4992x3328",
|
||||||
"2:3": "3328×4992",
|
"2:3": "3328x4992",
|
||||||
"21:9": "6197×2656",
|
"21:9": "6197x2656",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,7 +104,7 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
// Build supportedSizes from form values
|
// Build supportedSizes from form values
|
||||||
const sizes: Record<string, Record<string, string>> = {};
|
const sizes: Record<string, Record<string, string>> = {};
|
||||||
for (const tier of ["2K", "4K"]) {
|
for (const tier of ["1K", "2K", "4K"]) {
|
||||||
const selected: string[] = values[`size_${tier}`] || [];
|
const selected: string[] = values[`size_${tier}`] || [];
|
||||||
if (selected.length > 0) {
|
if (selected.length > 0) {
|
||||||
sizes[tier] = {};
|
sizes[tier] = {};
|
||||||
@@ -147,7 +157,7 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
setModal({ open: true, engine: engine || null });
|
setModal({ open: true, engine: engine || null });
|
||||||
if (engine) {
|
if (engine) {
|
||||||
const sizeFields: Record<string, string[]> = {};
|
const sizeFields: Record<string, string[]> = {};
|
||||||
for (const tier of ["2K", "4K"]) {
|
for (const tier of ["1K", "2K", "4K"]) {
|
||||||
sizeFields[`size_${tier}`] = Object.keys(engine.supportedSizes?.[tier] || {});
|
sizeFields[`size_${tier}`] = Object.keys(engine.supportedSizes?.[tier] || {});
|
||||||
}
|
}
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
@@ -161,6 +171,7 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
supportedModels: ['doubao-seedream-5-0-260128'],
|
supportedModels: ['doubao-seedream-5-0-260128'],
|
||||||
defaultSize: '2K',
|
defaultSize: '2K',
|
||||||
maxImageCount: 0,
|
maxImageCount: 0,
|
||||||
|
size_1K: ALL_RATIOS,
|
||||||
size_2K: ALL_RATIOS,
|
size_2K: ALL_RATIOS,
|
||||||
size_4K: ALL_RATIOS,
|
size_4K: ALL_RATIOS,
|
||||||
});
|
});
|
||||||
@@ -187,6 +198,16 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '1K 支持比例', key: 'sizes_1k', width: 260,
|
||||||
|
render: (_: any, r: ImageEngine) => {
|
||||||
|
const ratios = Object.keys(r.supportedSizes?.["1K"] || {});
|
||||||
|
if (ratios.length === 0) return <span style={{ color: '#bfbfbf' }}>-</span>;
|
||||||
|
return <Space size={2} wrap>{ratios.map(ratio => (
|
||||||
|
<Tag key={ratio} color="green">{ratio} {r.supportedSizes["1K"][ratio]}</Tag>
|
||||||
|
))}</Space>;
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '2K 支持比例', key: 'sizes_2k', width: 260,
|
title: '2K 支持比例', key: 'sizes_2k', width: 260,
|
||||||
render: (_: any, r: ImageEngine) => {
|
render: (_: any, r: ImageEngine) => {
|
||||||
@@ -294,7 +315,7 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{["2K", "4K"].map(tier => (
|
{["1K", "2K", "4K"].map(tier => (
|
||||||
<div key={tier} style={{
|
<div key={tier} style={{
|
||||||
background: '#fafbfc', borderRadius: 10, padding: '12px 16px',
|
background: '#fafbfc', borderRadius: 10, padding: '12px 16px',
|
||||||
marginBottom: 12, border: '1px solid #f0f0f5',
|
marginBottom: 12, border: '1px solid #f0f0f5',
|
||||||
@@ -318,6 +339,7 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
|
|
||||||
<Form.Item name="defaultSize" label="默认尺寸档位">
|
<Form.Item name="defaultSize" label="默认尺寸档位">
|
||||||
<Select size="large" options={[
|
<Select size="large" options={[
|
||||||
|
{ value: '1K', label: '1K' },
|
||||||
{ value: '2K', label: '2K' },
|
{ value: '2K', label: '2K' },
|
||||||
{ value: '4K', label: '4K' },
|
{ value: '4K', label: '4K' },
|
||||||
]} />
|
]} />
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ const AdminVideoEngines: React.FC = () => {
|
|||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
isActive: true, priority: 0,
|
isActive: true, priority: 0,
|
||||||
maxDuration: 15,
|
maxDuration: 30,
|
||||||
maxImageCount: 2,
|
maxImageCount: 2,
|
||||||
maxVideoCount: 0,
|
maxVideoCount: 0,
|
||||||
maxAudioCount: 0,
|
maxAudioCount: 0,
|
||||||
@@ -123,7 +123,7 @@ const AdminVideoEngines: React.FC = () => {
|
|||||||
supportsUniversalReference: true,
|
supportsUniversalReference: true,
|
||||||
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||||||
supportedResolutions: ['480p', '720p', '1080p'],
|
supportedResolutions: ['480p', '720p', '1080p'],
|
||||||
supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ async def create_menu_config(
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
return menu
|
return menu
|
||||||
|
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ async def update_menu_config(
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
return menu
|
return menu
|
||||||
|
|
||||||
|
|
||||||
@@ -121,4 +123,5 @@ async def delete_menu_config(
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
return {"message": "ok"}
|
return {"message": "ok"}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ async def create_package(
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
return _to_out(pkg)
|
return _to_out(pkg)
|
||||||
|
|
||||||
|
|
||||||
@@ -109,6 +110,7 @@ async def update_package(
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
return _to_out(pkg)
|
return _to_out(pkg)
|
||||||
|
|
||||||
|
|
||||||
@@ -142,4 +144,5 @@ async def delete_package(
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ async def save_config(
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ async def reset_default(
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -114,6 +116,7 @@ async def import_config(
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ async def create_user(
|
|||||||
),
|
),
|
||||||
ip=None,
|
ip=None,
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -233,6 +234,7 @@ async def update_user_menus(
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
return {"message": "ok"}
|
return {"message": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@@ -289,6 +291,7 @@ async def adjust_credits(
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
return {"message": "ok"}
|
return {"message": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@@ -319,6 +322,7 @@ async def update_user_status(
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
return {"message": "ok"}
|
return {"message": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@@ -1594,7 +1598,7 @@ async def update_system_config(
|
|||||||
if not config:
|
if not config:
|
||||||
raise HTTPException(status_code=404, detail="配置不存在")
|
raise HTTPException(status_code=404, detail="配置不存在")
|
||||||
config.value = str(req.value)
|
config.value = str(req.value)
|
||||||
await db.commit()
|
await db.flush()
|
||||||
await log_operation(
|
await log_operation(
|
||||||
db,
|
db,
|
||||||
admin.id,
|
admin.id,
|
||||||
@@ -1611,6 +1615,7 @@ async def update_system_config(
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
@@ -2185,7 +2190,7 @@ async def upload_pdf(
|
|||||||
key=config_key,
|
key=config_key,
|
||||||
value=url,
|
value=url,
|
||||||
))
|
))
|
||||||
await db.commit()
|
await db.flush()
|
||||||
await log_operation(
|
await log_operation(
|
||||||
db,
|
db,
|
||||||
admin.id,
|
admin.id,
|
||||||
@@ -2202,6 +2207,7 @@ async def upload_pdf(
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
return {"url": url}
|
return {"url": url}
|
||||||
|
|
||||||
@@ -2247,7 +2253,7 @@ async def upload_logo(
|
|||||||
value=url,
|
value=url,
|
||||||
description="网站Logo图片",
|
description="网站Logo图片",
|
||||||
))
|
))
|
||||||
await db.commit()
|
await db.flush()
|
||||||
await log_operation(
|
await log_operation(
|
||||||
db,
|
db,
|
||||||
admin.id,
|
admin.id,
|
||||||
@@ -2263,6 +2269,7 @@ async def upload_logo(
|
|||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
return {"url": url}
|
return {"url": url}
|
||||||
|
|
||||||
|
|||||||
+506
File diff suppressed because one or more lines are too long
-506
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/index-CYqrWKKz.js"></script>
|
<script type="module" crossorigin src="/assets/index-CYMIldpb.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-JhRVnnL-.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-JhRVnnL-.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -633,6 +633,26 @@ export async function removethree(projectId: string, stepId: string ,params: any
|
|||||||
export async function removefour(projectId: string, stepId: string ,params: any): Promise<any> {
|
export async function removefour(projectId: string, stepId: string ,params: any): Promise<any> {
|
||||||
return api.post(`/shot-replications/projects/${projectId}/steps/${stepId}/generate-video`, params);
|
return api.post(`/shot-replications/projects/${projectId}/steps/${stepId}/generate-video`, params);
|
||||||
}
|
}
|
||||||
|
// 重新分析
|
||||||
|
export async function reanalyzeShotReplication(taskSetId: string): Promise<any> {
|
||||||
|
return api.post(`/shot-replications/task-sets/${taskSetId}/reanalyze`);
|
||||||
|
}
|
||||||
|
// 删除片段
|
||||||
|
export async function deleteSegment(segmentId: string): Promise<void> {
|
||||||
|
await api.delete(`/shot-replications/segments/${segmentId}`);
|
||||||
|
}
|
||||||
|
// 重新分析片段
|
||||||
|
export async function reanalyzeSegment(segmentId: string): Promise<any> {
|
||||||
|
return api.post(`/shot-replications/segments/${segmentId}/reanalyze`);
|
||||||
|
}
|
||||||
|
// 删除拆镜项目
|
||||||
|
export async function deleteShotReplicationProject(taskSetId: string): Promise<void> {
|
||||||
|
await api.delete(`/shot-replications/task-sets/${taskSetId}`);
|
||||||
|
}
|
||||||
|
// 删除爆款开头复刻任务
|
||||||
|
export async function deleteHotOpeningReplicationTask(taskId: string): Promise<void> {
|
||||||
|
await api.delete(`/hot-opening-replications/tasks/${taskId}`);
|
||||||
|
}
|
||||||
// 获取地区信息
|
// 获取地区信息
|
||||||
export interface GetAreaParams {
|
export interface GetAreaParams {
|
||||||
level?: string;
|
level?: string;
|
||||||
@@ -797,7 +817,7 @@ export async function getHomeCaseHeader(): Promise<any> {
|
|||||||
return api.get(`/home-materials/categories`);
|
return api.get(`/home-materials/categories`);
|
||||||
}
|
}
|
||||||
// 首页素材按钮资源
|
// 首页素材按钮资源
|
||||||
export async function getHomeCaseButton(id: string,limit:number=5): Promise<any> {
|
export async function getHomeCaseButton(id: string,limit:number=10): Promise<any> {
|
||||||
return api.get(`/home-materials?category_id=${id}&limit_per_category=${limit}&include_empty_categories=false&response_mode=grouped&page=1&page_size=20`);
|
return api.get(`/home-materials?category_id=${id}&limit_per_category=${limit}&include_empty_categories=false&response_mode=grouped&page=1&page_size=20`);
|
||||||
}
|
}
|
||||||
export async function deleteResourcesMaterial(params:any): Promise<any> {
|
export async function deleteResourcesMaterial(params:any): Promise<any> {
|
||||||
|
|||||||
@@ -179,16 +179,16 @@ const StorageCard: React.FC<{ data: ResourceCapacityData | null }> = ({ data })
|
|||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: isOver ? '#ffffffff' : '#000000ff',
|
color: isOver ? '#000000ff' : '#000000ff',
|
||||||
padding: '0 8px',
|
padding: '0 8px',
|
||||||
|
|
||||||
}}>
|
}}>
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<DatabaseOutlined style={{ fontSize: 12, color: isOver ? '#ffffffff' :'#000000ff' }} />
|
<DatabaseOutlined style={{ fontSize: 12, color: isOver ? '#000000ff' :'#000000ff' }} />
|
||||||
{rawPercent.toFixed(1)}%
|
{rawPercent.toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
{data.enabled ? (
|
{data.enabled ? (
|
||||||
<span style={{ fontWeight: 500, color: isOver ? '#ffffffff' : '#000000ff' }}>
|
<span style={{ fontWeight: 500, color: isOver ? '#000000ff' : '#000000ff' }}>
|
||||||
{used.toFixed(2)} / {total.toFixed(2)} {unit}
|
{used.toFixed(2)} / {total.toFixed(2)} {unit}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,61 +1,79 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { Modal, Tooltip } from 'antd';
|
|
||||||
import { HistoryOutlined, UserOutlined, FolderOpenOutlined, PlusOutlined, TeamOutlined } from '@ant-design/icons';
|
import { Popover, Tooltip } from 'antd';
|
||||||
|
import { FolderOpenOutlined, DatabaseOutlined, UserOutlined, TeamOutlined } from '@ant-design/icons';
|
||||||
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset, UploadResourceHistoryItem } from '../types';
|
import type { PrivatePortraitLibraryType, PrivatePortraitSelectableAsset, UploadResourceHistoryItem } from '../types';
|
||||||
|
|
||||||
import PrivatePortraitAssetPicker from './privatePortrait/picker/AssetPicker';
|
import PrivatePortraitAssetPicker from './privatePortrait/picker/AssetPicker';
|
||||||
import UploadResourceHistoryPicker from './uploadResource/UploadResourceHistoryPicker';
|
import UploadResourceHistoryPicker from './uploadResource/UploadResourceHistoryPicker';
|
||||||
|
|
||||||
interface UploadSelectorProps {
|
interface UploadSelectorProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
accept?: string;
|
accept?: string;
|
||||||
|
multiple?: boolean;
|
||||||
onLocalSelect?: (files: File[]) => void;
|
onLocalSelect?: (files: File[]) => void;
|
||||||
|
|
||||||
onHistorySelect?: (items: UploadResourceHistoryItem[]) => void;
|
onHistorySelect?: (items: UploadResourceHistoryItem[]) => void;
|
||||||
/** 兼容旧页面:由 UploadSelector 内部打开素材选择器,确认后回传素材数组。 */
|
/** 兼容旧页面:由 UploadSelector 内部打开素材选择器,确认后回传素材数组。 */
|
||||||
|
|
||||||
onPortraitSelect?: (items: PrivatePortraitSelectableAsset[]) => void;
|
onPortraitSelect?: (items: PrivatePortraitSelectableAsset[]) => void;
|
||||||
/** 新页面推荐:只选择素材库类型,父组件自行打开统一选择器。 */
|
|
||||||
onPortraitLibrarySelect?: (libraryType: PrivatePortraitLibraryType) => void;
|
onPortraitLibrarySelect?: (libraryType: PrivatePortraitLibraryType) => void;
|
||||||
uploading?: boolean;
|
uploading?: boolean;
|
||||||
tooltipTitle?: string;
|
tooltipTitle?: string;
|
||||||
|
maxImageCount?: number;
|
||||||
|
maxVideoCount?: number;
|
||||||
|
usedImageCount?: number;
|
||||||
|
usedVideoCount?: number;
|
||||||
|
usedVideoDuration?: number;
|
||||||
|
maxVideoDuration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UploadSelector: React.FC<UploadSelectorProps> = ({
|
const UploadSelector: React.FC<UploadSelectorProps> = ({
|
||||||
children,
|
children,
|
||||||
accept = 'image/*,video/*',
|
accept = 'image/*,video/*',
|
||||||
|
multiple = true,
|
||||||
onLocalSelect,
|
onLocalSelect,
|
||||||
onHistorySelect,
|
onHistorySelect,
|
||||||
onPortraitSelect,
|
onPortraitSelect,
|
||||||
onPortraitLibrarySelect,
|
onPortraitLibrarySelect,
|
||||||
uploading,
|
uploading,
|
||||||
tooltipTitle,
|
tooltipTitle,
|
||||||
|
maxImageCount,
|
||||||
|
maxVideoCount,
|
||||||
|
usedImageCount,
|
||||||
|
usedVideoCount,
|
||||||
|
usedVideoDuration,
|
||||||
|
maxVideoDuration,
|
||||||
}) => {
|
}) => {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
|
||||||
const [historyModalVisible, setHistoryModalVisible] = useState(false);
|
|
||||||
const [portraitPickerOpen, setPortraitPickerOpen] = useState(false);
|
const [portraitPickerOpen, setPortraitPickerOpen] = useState(false);
|
||||||
const [portraitLibraryType, setPortraitLibraryType] = useState<PrivatePortraitLibraryType>('real_person');
|
const [portraitLibraryType, setPortraitLibraryType] = useState<PrivatePortraitLibraryType>('real_person');
|
||||||
|
const [historyModalVisible, setHistoryModalVisible] = useState(false);
|
||||||
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
|
|
||||||
const handleLocalSelect = () => {
|
const handleLocalSelect = () => {
|
||||||
|
setPopoverOpen(false);
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
if (files && onLocalSelect) {
|
if (files && onLocalSelect) {
|
||||||
onLocalSelect(Array.from(files));
|
const fileList = Array.from(files);
|
||||||
|
onLocalSelect(fileList);
|
||||||
}
|
}
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = '';
|
fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
if (!uploading) {
|
|
||||||
setModalVisible(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openPortraitPicker = (libraryType: PrivatePortraitLibraryType) => {
|
const openPortraitPicker = (libraryType: PrivatePortraitLibraryType) => {
|
||||||
setModalVisible(false);
|
setPopoverOpen(false);
|
||||||
|
if (onPortraitSelect) {
|
||||||
|
setPortraitLibraryType(libraryType);
|
||||||
|
setPortraitPickerOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (onPortraitLibrarySelect) {
|
if (onPortraitLibrarySelect) {
|
||||||
onPortraitLibrarySelect(libraryType);
|
onPortraitLibrarySelect(libraryType);
|
||||||
return;
|
return;
|
||||||
@@ -64,42 +82,101 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
|||||||
setPortraitPickerOpen(true);
|
setPortraitPickerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = [
|
const handleHistorySelect = () => {
|
||||||
{
|
setPopoverOpen(false);
|
||||||
key: 'history',
|
|
||||||
label: '历史记录',
|
|
||||||
icon: <HistoryOutlined style={{ fontSize: 20, color: '#6366f1' }} />,
|
|
||||||
description: '从历史上传记录中选择',
|
|
||||||
onClick: () => {
|
|
||||||
setModalVisible(false);
|
|
||||||
setHistoryModalVisible(true);
|
setHistoryModalVisible(true);
|
||||||
},
|
};
|
||||||
},
|
|
||||||
{
|
const content = (
|
||||||
key: 'real_person',
|
<div style={{ padding: '4px 0', minWidth: 180 }}>
|
||||||
label: '真人素材',
|
<div
|
||||||
icon: <UserOutlined style={{ fontSize: 20, color: '#ec4899' }} />,
|
onClick={() => {
|
||||||
description: '从真人私域素材库中选择',
|
|
||||||
onClick: () => openPortraitPicker('real_person'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'aigc_virtual',
|
|
||||||
label: '虚拟素材',
|
|
||||||
icon: <TeamOutlined style={{ fontSize: 20, color: '#8b5cf6' }} />,
|
|
||||||
description: '从虚拟私域素材库中选择',
|
|
||||||
onClick: () => openPortraitPicker('aigc_virtual'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'local',
|
|
||||||
label: '本地选取',
|
|
||||||
icon: <FolderOpenOutlined style={{ fontSize: 20, color: '#10b981' }} />,
|
|
||||||
description: '从本地电脑选择文件',
|
|
||||||
onClick: () => {
|
|
||||||
setModalVisible(false);
|
|
||||||
handleLocalSelect();
|
handleLocalSelect();
|
||||||
},
|
}}
|
||||||
},
|
style={{
|
||||||
];
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
padding: '8px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = '#f1f5f9';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderOpenOutlined style={{ fontSize: 14, color: '#64748b' }} />
|
||||||
|
<span style={{ fontSize: 14, color: '#334155' }}>
|
||||||
|
|
||||||
|
本地上传
|
||||||
|
{/* {multiple ? '本地上传(可多选拖拽)' : '本地上传'} */}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={handleHistorySelect}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
padding: '8px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = '#f1f5f9';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DatabaseOutlined style={{ fontSize: 14, color: '#64748b' }} />
|
||||||
|
<span style={{ fontSize: 14, color: '#334155' }}>从资产中选择</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => openPortraitPicker('real_person')}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
padding: '8px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = '#f1f5f9';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserOutlined style={{ fontSize: 14, color: '#64748b' }} />
|
||||||
|
<span style={{ fontSize: 14, color: '#334155' }}>真人素材库</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => openPortraitPicker('aigc_virtual')}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
padding: '8px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = '#f1f5f9';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TeamOutlined style={{ fontSize: 14, color: '#64748b' }} />
|
||||||
|
<span style={{ fontSize: 14, color: '#334155' }}>虚拟素材库</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -107,93 +184,44 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
|||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept={accept}
|
accept={accept}
|
||||||
multiple
|
multiple={multiple}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
/>
|
/>
|
||||||
{tooltipTitle ? (
|
{tooltipTitle ? (
|
||||||
<Tooltip title={tooltipTitle}>
|
<Tooltip title={tooltipTitle}>
|
||||||
<div onClick={handleClick} style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
|
<Popover
|
||||||
|
content={content}
|
||||||
|
open={popoverOpen}
|
||||||
|
onOpenChange={setPopoverOpen}
|
||||||
|
trigger="click"
|
||||||
|
placement="bottomLeft"
|
||||||
|
>
|
||||||
|
<div style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
</Popover>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<div onClick={handleClick} style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
|
<Popover
|
||||||
|
content={content}
|
||||||
|
open={popoverOpen}
|
||||||
|
onOpenChange={setPopoverOpen}
|
||||||
|
trigger="click"
|
||||||
|
placement="bottomLeft"
|
||||||
|
>
|
||||||
|
<div style={{ cursor: uploading ? 'not-allowed' : 'pointer' }}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
</Popover>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal
|
|
||||||
title="选择上传来源"
|
|
||||||
open={modalVisible}
|
|
||||||
onCancel={() => setModalVisible(false)}
|
|
||||||
footer={null}
|
|
||||||
width={400}
|
|
||||||
centered
|
|
||||||
destroyOnHidden
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, paddingTop: 8 }}>
|
|
||||||
{options.map((option) => (
|
|
||||||
<div
|
|
||||||
key={option.key}
|
|
||||||
onClick={option.onClick}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 16,
|
|
||||||
padding: '16px 20px',
|
|
||||||
borderRadius: 12,
|
|
||||||
background: '#f8fafc',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
border: '1px solid transparent',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = '#fff';
|
|
||||||
e.currentTarget.style.borderColor = '#e2e8f0';
|
|
||||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.04)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = '#f8fafc';
|
|
||||||
e.currentTarget.style.borderColor = 'transparent';
|
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
borderRadius: 12,
|
|
||||||
background: '#fff',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.icon}
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontSize: 15, fontWeight: 600, color: '#1e293b', marginBottom: 2 }}>
|
|
||||||
{option.label}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 13, color: '#64748b' }}>
|
|
||||||
{option.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<PlusOutlined style={{ fontSize: 14, color: '#94a3b8' }} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<UploadResourceHistoryPicker
|
<UploadResourceHistoryPicker
|
||||||
open={historyModalVisible}
|
open={historyModalVisible}
|
||||||
onClose={() => setHistoryModalVisible(false)}
|
onClose={() => setHistoryModalVisible(false)}
|
||||||
onSelect={(items) => {
|
onSelect={(items) => {
|
||||||
onHistorySelect?.(items);
|
onHistorySelect?.(items);
|
||||||
setHistoryModalVisible(false);
|
setHistoryModalVisible(false);
|
||||||
setModalVisible(false);
|
|
||||||
}}
|
}}
|
||||||
allowedTypes={accept.includes('audio') ? ['image', 'video', 'audio'] : accept.includes('video') ? ['image', 'video'] : ['image']}
|
allowedTypes={accept.includes('audio') ? ['image', 'video', 'audio'] : accept.includes('video') ? ['image', 'video'] : ['image']}
|
||||||
/>
|
/>
|
||||||
@@ -206,6 +234,14 @@ const UploadSelector: React.FC<UploadSelectorProps> = ({
|
|||||||
onPortraitSelect?.(assets);
|
onPortraitSelect?.(assets);
|
||||||
setPortraitPickerOpen(false);
|
setPortraitPickerOpen(false);
|
||||||
}}
|
}}
|
||||||
|
accept={accept}
|
||||||
|
maxCount={accept === 'image/*' && !multiple ? 1 : undefined}
|
||||||
|
maxImageCount={maxImageCount}
|
||||||
|
maxVideoCount={maxVideoCount}
|
||||||
|
usedImageCount={usedImageCount}
|
||||||
|
usedVideoCount={usedVideoCount}
|
||||||
|
usedVideoDuration={usedVideoDuration}
|
||||||
|
maxVideoDuration={maxVideoDuration}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Button, Empty, Popconfirm, Space, Tag, Tooltip } from 'antd';
|
import { Button, Card, Empty, Modal, Popconfirm, Space, Tag, Tooltip } from 'antd';
|
||||||
import { DeleteOutlined, PictureOutlined, ReloadOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, EyeOutlined, PictureOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||||
import type { PrivatePortraitAsset } from '../../../types';
|
import type { PrivatePortraitAsset } from '../../../types';
|
||||||
|
|
||||||
const statusColor: Record<string, string> = {
|
const statusColor: Record<string, string> = {
|
||||||
@@ -12,11 +12,20 @@ const statusColor: Record<string, string> = {
|
|||||||
delete_failed: 'red',
|
delete_failed: 'red',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const statusText: Record<string, string> = {
|
||||||
|
Active: '入库成功',
|
||||||
|
Processing: '入库处理中',
|
||||||
|
Failed: '入库失败',
|
||||||
|
local_deleted: '本地已删除',
|
||||||
|
remote_deleted: '远程已删除',
|
||||||
|
delete_failed: '删除失败',
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: PrivatePortraitAsset[];
|
items: PrivatePortraitAsset[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onSync: (assetId: string) => void;
|
|
||||||
onDelete: (assetId: string) => void;
|
onDelete: (assetId: string) => void;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildPreviewUrl = (url?: string | null) => {
|
const buildPreviewUrl = (url?: string | null) => {
|
||||||
@@ -39,53 +48,114 @@ const formatDuration = (value?: number | null) => {
|
|||||||
return `${duration.toFixed(duration >= 10 ? 0 : 1)}s`;
|
return `${duration.toFixed(duration >= 10 ? 0 : 1)}s`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PrivatePortraitAssetGrid: React.FC<Props> = ({ items, onSync, onDelete }) => {
|
const PrivatePortraitAssetGrid: React.FC<Props> = ({ items, onDelete, onRefresh }) => {
|
||||||
if (!items.length) return <Empty description="暂无真人素材" />;
|
const pollingRef = useRef<number | null>(null);
|
||||||
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState('');
|
||||||
|
const [previewType, setPreviewType] = useState<'Image' | 'Video'>('Image');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const needsPolling = items.some(
|
||||||
|
(item) => item.status !== 'Failed' && item.status !== 'Active'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (needsPolling && onRefresh) {
|
||||||
|
if (!pollingRef.current) {
|
||||||
|
pollingRef.current = window.setInterval(() => {
|
||||||
|
onRefresh();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (pollingRef.current) {
|
||||||
|
clearInterval(pollingRef.current);
|
||||||
|
pollingRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (pollingRef.current) {
|
||||||
|
clearInterval(pollingRef.current);
|
||||||
|
pollingRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [items, onRefresh]);
|
||||||
|
|
||||||
|
const openPreview = (asset: PrivatePortraitAsset) => {
|
||||||
|
const url = getAssetPreviewUrl(asset);
|
||||||
|
if (!url) return;
|
||||||
|
setPreviewUrl(url);
|
||||||
|
setPreviewType(asset.assetType === 'Video' ? 'Video' : 'Image');
|
||||||
|
setPreviewOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!items.length) return <Empty description="暂无素材" />;
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 14 }}>
|
<>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 14 }}>
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const isVideo = item.assetType === 'Video';
|
const isVideo = item.assetType === 'Video';
|
||||||
const previewUrl = getAssetPreviewUrl(item);
|
const previewUrl = getAssetPreviewUrl(item);
|
||||||
return (
|
return (
|
||||||
<div key={item.id} style={{ border: '1px solid #e2e8f0', borderRadius: 12, overflow: 'hidden', background: '#fff' }}>
|
<Card
|
||||||
<div style={{ height: 150, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
|
key={item.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' }}>
|
||||||
{previewUrl ? (
|
{previewUrl ? (
|
||||||
isVideo ? (
|
isVideo && item.videoCoverUrl ? (
|
||||||
|
<img src={previewUrl} alt={item.name || '视频封面'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
) : isVideo ? (
|
||||||
<video src={previewUrl} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
<video src={previewUrl} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
) : (
|
) : (
|
||||||
<img src={previewUrl} alt={item.name || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
<img src={previewUrl} alt={item.name || ''} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
)
|
)
|
||||||
) : isVideo ? (
|
) : isVideo ? (
|
||||||
<VideoCameraOutlined style={{ fontSize: 32, color: '#94a3b8' }} />
|
<VideoCameraOutlined style={{ fontSize: 42, color: '#64748b' }} />
|
||||||
) : (
|
) : (
|
||||||
<PictureOutlined style={{ fontSize: 32, color: '#94a3b8' }} />
|
<PictureOutlined style={{ fontSize: 42, color: '#64748b' }} />
|
||||||
|
)}
|
||||||
|
{isVideo && <Tag color="blue" style={{ position: 'absolute', left: 10, top: 10 }}>视频</Tag>}
|
||||||
|
{previewUrl && (
|
||||||
|
<Button size="small" shape="circle" icon={<EyeOutlined />} style={{ position: 'absolute', right: 10, top: 10 }} onClick={() => openPreview(item)} />
|
||||||
)}
|
)}
|
||||||
{isVideo && (
|
{isVideo && (
|
||||||
<div style={{ position: 'absolute', left: 8, bottom: 8, padding: '2px 6px', borderRadius: 8, background: 'rgba(15,23,42,0.72)', color: '#fff', fontSize: 12 }}>
|
<div style={{ position: 'absolute', left: 10, bottom: 10, padding: '2px 6px', borderRadius: 8, background: 'rgba(15,23,42,0.72)', color: '#fff', fontSize: 12 }}>
|
||||||
{formatDuration(item.videoDuration)}
|
{formatDuration(item.videoDuration)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: 10 }}>
|
)}
|
||||||
<Tooltip title={item.name || item.remoteAssetId}>
|
>
|
||||||
<div style={{ fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.name || item.remoteAssetId}</div>
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||||
|
<Tooltip title={item.name || item.remoteAssetId || item.id}>
|
||||||
|
<div style={{ fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.name || item.remoteAssetId || '未命名素材'}</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Space wrap size={4} style={{ marginTop: 8 }}>
|
<Space wrap size={4}>
|
||||||
<Tag color={statusColor[item.status] || 'default'}>{item.status}</Tag>
|
<Tag color={statusColor[item.status] || 'default'}>{statusText[item.status] || item.status}</Tag>
|
||||||
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
|
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
{item.errorMessage && <div style={{ color: '#ef4444', fontSize: 12, marginTop: 6 }}>{item.errorMessage}</div>}
|
<Space size={6} wrap>
|
||||||
<Space style={{ marginTop: 10 }} size={6}>
|
|
||||||
<Button size="small" icon={<ReloadOutlined />} onClick={() => onSync(item.id)}>刷新</Button>
|
|
||||||
<Popconfirm title="确认删除这个素材吗?" onConfirm={() => onDelete(item.id)}>
|
<Popconfirm title="确认删除这个素材吗?" onConfirm={() => onDelete(item.id)}>
|
||||||
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</Space>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState, useEffect } from 'react';
|
||||||
import { Tabs, Typography } from 'antd';
|
import { Card, Col, Row, Tabs, Typography, message } from 'antd';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { getPrivatePortraitConfig, getPrivatePortraitProjects, getPrivatePortraitVirtualConfig, getPrivatePortraitVirtualProjects } from '../../../api';
|
||||||
|
import type { PrivatePortraitConfig, PrivatePortraitProject } from '../../../types';
|
||||||
import RealPersonLibraryPanel from './RealPersonLibraryPanel';
|
import RealPersonLibraryPanel from './RealPersonLibraryPanel';
|
||||||
import VirtualMaterialPanel from './VirtualMaterialPanel';
|
import VirtualMaterialPanel from './VirtualMaterialPanel';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
type PrivatePortraitTabKey = 'real_person' | 'aigc_virtual';
|
type PrivatePortraitTabKey = 'real_person' | 'aigc_virtual';
|
||||||
|
|
||||||
@@ -15,6 +17,48 @@ const normalizeTabKey = (value?: string | null): PrivatePortraitTabKey => (
|
|||||||
const PrivatePortraitLibraryPanel: React.FC = () => {
|
const PrivatePortraitLibraryPanel: React.FC = () => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [activeKey, setActiveKey] = useState<PrivatePortraitTabKey>(() => normalizeTabKey(searchParams.get('portraitTab')));
|
const [activeKey, setActiveKey] = useState<PrivatePortraitTabKey>(() => normalizeTabKey(searchParams.get('portraitTab')));
|
||||||
|
const [config, setConfig] = useState<PrivatePortraitConfig | null>(null);
|
||||||
|
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
if (activeKey === 'aigc_virtual') {
|
||||||
|
const [configRes, projectsRes] = await Promise.all([
|
||||||
|
getPrivatePortraitVirtualConfig(),
|
||||||
|
getPrivatePortraitVirtualProjects({ page: 1, pageSize: 100, status: 'active' }),
|
||||||
|
]);
|
||||||
|
setConfig(configRes);
|
||||||
|
const projectList = projectsRes.items || [];
|
||||||
|
setProjects(projectList);
|
||||||
|
setSelectedProjectId((prev) => prev && projectList.some((item) => item.id === prev) ? prev : projectList[0]?.id);
|
||||||
|
} else {
|
||||||
|
const [configRes, projectsRes] = await Promise.all([
|
||||||
|
getPrivatePortraitConfig(),
|
||||||
|
getPrivatePortraitProjects({ pageSize: 100, status: 'active' }),
|
||||||
|
]);
|
||||||
|
setConfig(configRes);
|
||||||
|
const projectList = projectsRes.items || [];
|
||||||
|
setProjects(projectList);
|
||||||
|
setSelectedProjectId((prev) => prev && projectList.some((item) => item.id === prev) ? prev : projectList[0]?.id);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.message || '加载数据失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadData();
|
||||||
|
}, [activeKey]);
|
||||||
|
|
||||||
|
const selectedProject = useMemo(
|
||||||
|
() => projects.find((item) => item.id === selectedProjectId) || null,
|
||||||
|
[projects, selectedProjectId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const quotaText = useMemo(() => {
|
||||||
|
if (!config) return '额度加载中';
|
||||||
|
return `已用 ${config.usedAssetCount || 0} / ${config.assetLimit || 0} 个素材,剩余 ${config.remainingAssetCount || 0}`;
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
const items = useMemo(() => [
|
const items = useMemo(() => [
|
||||||
{
|
{
|
||||||
@@ -45,6 +89,33 @@ const PrivatePortraitLibraryPanel: React.FC = () => {
|
|||||||
<Title level={4} style={{ margin: 0 }}>私域素材库</Title>
|
<Title level={4} style={{ margin: 0 }}>私域素材库</Title>
|
||||||
<Text type="secondary">统一管理真人素材和虚拟素材。真人项目组需先完成人脸认证,虚拟项目组会同步创建火山 AIGC Asset Group。</Text>
|
<Text type="secondary">统一管理真人素材和虚拟素材。真人项目组需先完成人脸认证,虚拟项目组会同步创建火山 AIGC Asset Group。</Text>
|
||||||
</div>
|
</div>
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Card style={{ borderRadius: 16, background: 'linear-gradient(135deg,#f5f3ff,#fff)' }}>
|
||||||
|
<Text type="secondary">素材总额度</Text>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}</div>
|
||||||
|
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>真人/虚拟共用,图片/视频共用;音频暂不开放。</Paragraph>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Card style={{ borderRadius: 16 }}>
|
||||||
|
<Text type="secondary">项目组</Text>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{projects.length}</div>
|
||||||
|
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>
|
||||||
|
{activeKey === 'aigc_virtual'
|
||||||
|
? '虚拟人像项目会同步创建火山 AIGC Asset Group。'
|
||||||
|
: '真人项目组需完成人脸认证后才可上传素材。'}
|
||||||
|
</Paragraph>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Card style={{ borderRadius: 16 }}>
|
||||||
|
<Text type="secondary">当前项目素材</Text>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{selectedProject?.assetCount || 0}</div>
|
||||||
|
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>仅 Active 状态素材可在 AI 创作中引用。</Paragraph>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
<Tabs
|
<Tabs
|
||||||
activeKey={activeKey}
|
activeKey={activeKey}
|
||||||
onChange={handleTabChange}
|
onChange={handleTabChange}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Button, Card, Popconfirm, Space, Tag, Typography, message } from 'antd';
|
import { Button, Card, Empty, Input, Pagination, Popconfirm, Select, Space, Spin, Typography, message } from 'antd';
|
||||||
import { DeleteOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types';
|
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types';
|
||||||
import { deletePrivatePortraitAsset, deletePrivatePortraitProject, getPrivatePortraitAssets, syncPrivatePortraitAsset } from '../../../api';
|
import { deletePrivatePortraitAsset, deletePrivatePortraitProject, getPrivatePortraitAssets } from '../../../api';
|
||||||
import PrivatePortraitAssetGrid from './AssetGrid';
|
import PrivatePortraitAssetGrid from './AssetGrid';
|
||||||
import PrivatePortraitAssetUpload from './AssetUpload';
|
import PrivatePortraitAssetUpload from './AssetUpload';
|
||||||
|
|
||||||
@@ -16,12 +16,27 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
|
|||||||
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
|
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [uploadOpen, setUploadOpen] = useState(false);
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [assetStatus, setAssetStatus] = useState<string>();
|
||||||
|
const [assetType, setAssetType] = useState<string>();
|
||||||
|
const [assetPage, setAssetPage] = useState(1);
|
||||||
|
const [assetPageSize, setAssetPageSize] = useState(20);
|
||||||
|
const [assetTotal, setAssetTotal] = useState(0);
|
||||||
|
|
||||||
const loadAssets = async () => {
|
const loadAssets = async (page = assetPage, pageSize = assetPageSize) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await getPrivatePortraitAssets(project.id, { pageSize: 100 });
|
const res = await getPrivatePortraitAssets(project.id, {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
keyword,
|
||||||
|
status: assetStatus,
|
||||||
|
assetType: assetType as any,
|
||||||
|
});
|
||||||
setAssets(res.items);
|
setAssets(res.items);
|
||||||
|
setAssetTotal(res.total || 0);
|
||||||
|
setAssetPage(page);
|
||||||
|
setAssetPageSize(pageSize);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e?.message || '加载素材失败');
|
message.error(e?.message || '加载素材失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -29,23 +44,12 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { loadAssets(); }, [project.id]);
|
useEffect(() => { loadAssets(1, assetPageSize); }, [project.id, keyword, assetStatus, assetType]);
|
||||||
|
|
||||||
const handleSync = async (assetId: string) => {
|
|
||||||
try {
|
|
||||||
await syncPrivatePortraitAsset(assetId);
|
|
||||||
await loadAssets();
|
|
||||||
onChanged();
|
|
||||||
message.success('素材状态已刷新');
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.message || '刷新失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteAsset = async (assetId: string) => {
|
const handleDeleteAsset = async (assetId: string) => {
|
||||||
try {
|
try {
|
||||||
await deletePrivatePortraitAsset(assetId);
|
await deletePrivatePortraitAsset(assetId);
|
||||||
await loadAssets();
|
await loadAssets(assetPage, assetPageSize);
|
||||||
onChanged();
|
onChanged();
|
||||||
message.success('素材已删除');
|
message.success('素材已删除');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -67,26 +71,76 @@ const PrivatePortraitProjectDetail: React.FC<Props> = ({ project, onDeleted, onC
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title={<Space><span>{project.name}</span><Tag color={canUpload ? 'green' : 'processing'}>{project.status}</Tag></Space>}
|
title={<Space><span>{project.name}</span></Space>}
|
||||||
extra={(
|
extra={(
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" icon={<UploadOutlined />} disabled={!canUpload} onClick={() => setUploadOpen(true)}>上传素材</Button>
|
<Button type="primary" icon={<UploadOutlined />} disabled={!canUpload} onClick={() => setUploadOpen(true)}>上传素材</Button>
|
||||||
<Button icon={<ReloadOutlined />} onClick={loadAssets} loading={loading}>刷新</Button>
|
|
||||||
<Popconfirm title="确认删除这个真人素材项目组吗?" onConfirm={handleDeleteProject}>
|
<Popconfirm title="确认删除这个真人素材项目组吗?" onConfirm={handleDeleteProject}>
|
||||||
<Button danger icon={<DeleteOutlined />}>删除项目组</Button>
|
<Button danger icon={<DeleteOutlined />}>删除项目组</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
style={{ borderRadius: 12 }}
|
style={{ borderRadius: 16 }}
|
||||||
>
|
>
|
||||||
<Typography.Paragraph style={{ color: '#64748b' }}>{project.description || '暂无描述'}</Typography.Paragraph>
|
|
||||||
{!canUpload && (
|
{!canUpload && (
|
||||||
<Typography.Paragraph style={{ color: '#f97316' }}>
|
<Typography.Paragraph style={{ color: '#f97316' }}>
|
||||||
项目组未完成真人认证,暂不能上传素材。请重新创建项目组并完成手机扫码认证。
|
项目组未完成真人认证,暂不能上传素材。请重新创建项目组并完成手机扫码认证。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
)}
|
)}
|
||||||
<PrivatePortraitAssetGrid items={assets} loading={loading} onSync={handleSync} onDelete={handleDeleteAsset} />
|
<Space style={{ width: '100%', marginBottom: 16 }} wrap>
|
||||||
<PrivatePortraitAssetUpload projectId={project.id} open={uploadOpen} onClose={() => setUploadOpen(false)} onSuccess={() => { loadAssets(); onChanged(); }} />
|
<Input.Search
|
||||||
|
allowClear
|
||||||
|
placeholder="搜索素材名称"
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
onSearch={() => loadAssets(1, assetPageSize)}
|
||||||
|
style={{ width: 240 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="素材状态"
|
||||||
|
value={assetStatus}
|
||||||
|
onChange={(value) => setAssetStatus(value)}
|
||||||
|
style={{ width: 150 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'Processing', label: '处理中' },
|
||||||
|
{ value: 'Active', label: '可用' },
|
||||||
|
{ value: 'Failed', label: '失败' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="素材类型"
|
||||||
|
value={assetType}
|
||||||
|
onChange={(value) => setAssetType(value)}
|
||||||
|
style={{ width: 130 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'Image', label: '图片' },
|
||||||
|
{ value: 'Video', label: '视频' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button onClick={() => loadAssets(1, assetPageSize)}>筛选</Button>
|
||||||
|
</Space>
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
{assets.length === 0 ? (
|
||||||
|
<Empty description="暂无素材,上传图片/视频后会异步入库" style={{ marginTop: 80 }} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PrivatePortraitAssetGrid items={assets} loading={loading} onDelete={handleDeleteAsset} onRefresh={() => loadAssets(assetPage, assetPageSize)} />
|
||||||
|
<div style={{ textAlign: 'right', marginTop: 16 }}>
|
||||||
|
<Pagination
|
||||||
|
current={assetPage}
|
||||||
|
pageSize={assetPageSize}
|
||||||
|
total={assetTotal}
|
||||||
|
showSizeChanger
|
||||||
|
showTotal={(value) => `共 ${value} 个素材`}
|
||||||
|
onChange={(page, size) => loadAssets(page, size)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
<PrivatePortraitAssetUpload projectId={project.id} open={uploadOpen} onClose={() => setUploadOpen(false)} onSuccess={() => { loadAssets(1, assetPageSize); onChanged(); }} />
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button, Empty, List, Tag } from 'antd';
|
import { Empty, Space, Tag, Typography } from 'antd';
|
||||||
import type { PrivatePortraitProject } from '../../../types';
|
import type { PrivatePortraitProject } from '../../../types';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: PrivatePortraitProject[];
|
items: PrivatePortraitProject[];
|
||||||
selectedId?: string | null;
|
selectedId?: string | null;
|
||||||
@@ -11,24 +13,40 @@ interface Props {
|
|||||||
const PrivatePortraitProjectList: React.FC<Props> = ({ items, selectedId, onSelect }) => {
|
const PrivatePortraitProjectList: React.FC<Props> = ({ items, selectedId, onSelect }) => {
|
||||||
if (!items.length) return <Empty description="暂无项目组" />;
|
if (!items.length) return <Empty description="暂无项目组" />;
|
||||||
return (
|
return (
|
||||||
<List
|
<Space direction="vertical" style={{ width: '100%' }} size={10}>
|
||||||
dataSource={items}
|
{items.map((project) => {
|
||||||
renderItem={(item) => (
|
const active = selectedId === project.id;
|
||||||
<List.Item style={{ padding: 0, marginBottom: 8 }}>
|
return (
|
||||||
<Button
|
<div
|
||||||
block
|
key={project.id}
|
||||||
onClick={() => onSelect(item)}
|
onClick={() => onSelect(project)}
|
||||||
style={{ height: 'auto', padding: 12, textAlign: 'left', borderColor: selectedId === item.id ? '#8b5cf6' : '#e2e8f0' }}
|
style={{
|
||||||
|
padding: 14,
|
||||||
|
borderRadius: 14,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: active ? '1px solid #8b5cf6' : '1px solid #e2e8f0',
|
||||||
|
background: active ? '#f5f3ff' : '#fff',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
<Space style={{ width: '100%', justifyContent: 'space-between' }} align="start">
|
||||||
<strong>{item.name}</strong>
|
<div style={{ minWidth: 0 }}>
|
||||||
<Tag color={item.activeAssetCount > 0 ? 'green' : 'default'}>{item.activeAssetCount}/{item.assetCount}</Tag>
|
<Text strong ellipsis style={{ display: 'block' }}>{project.name}</Text>
|
||||||
|
{project.description && <Text type="secondary" ellipsis style={{ display: 'block', fontSize: 12 }}>{project.description}</Text>}
|
||||||
</div>
|
</div>
|
||||||
{item.description && <div style={{ color: '#64748b', fontSize: 12, marginTop: 4 }}>{item.description}</div>}
|
{/* <Tag color={project.status === 'active' ? 'green' : 'processing'}>
|
||||||
</Button>
|
{project.status === 'active' ? '可用' : project.status}
|
||||||
</List.Item>
|
</Tag> */}
|
||||||
)}
|
</Space>
|
||||||
/>
|
<Space wrap size={4} style={{ marginTop: 10 }}>
|
||||||
|
<Tag>总 {project.assetCount || 0}</Tag>
|
||||||
|
<Tag color="green">图 {project.imageAssetCount || 0}</Tag>
|
||||||
|
<Tag color="blue">视频 {project.videoAssetCount || 0}</Tag>
|
||||||
|
{/* <Tag color="success">Active {project.activeAssetCount || 0}</Tag> */}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Button, Card, Col, Form, Input, Modal, QRCode, Row, Space, Spin, Typography, message } from 'antd';
|
import { Button, Card, Col, Empty, Form, Input, Modal, QRCode, Row, Space, Spin, Typography, message } from 'antd';
|
||||||
import { CheckCircleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { CheckCircleOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
import type { PrivatePortraitProject, PrivatePortraitValidateSession } from '../../../types';
|
import type { PrivatePortraitProject, PrivatePortraitValidateSession } from '../../../types';
|
||||||
import { createPrivatePortraitProject, getPrivatePortraitProjects, getPrivatePortraitValidateSession } from '../../../api';
|
import { createPrivatePortraitProject, getPrivatePortraitProjects, getPrivatePortraitValidateSession } from '../../../api';
|
||||||
@@ -117,27 +117,36 @@ const RealPersonLibraryPanel: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<div>
|
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>真人素材库</Typography.Title>
|
<Typography.Title level={4} style={{ margin: 0 }}>真人素材库</Typography.Title>
|
||||||
<Typography.Text type="secondary">创建项目组时先完成真人认证,认证成功后项目组才会正式创建并可上传素材。</Typography.Text>
|
<Typography.Text type="secondary">创建项目组时先完成真人认证,认证成功后项目组才会正式创建并可上传素材。</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
<Space>
|
|
||||||
<Button icon={<ReloadOutlined />} onClick={loadProjects} loading={loading}>刷新</Button>
|
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>新建项目组</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col xs={24} md={7} lg={6}>
|
<Col xs={24} lg={7}>
|
||||||
<Card title="项目组" style={{ borderRadius: 12 }}>
|
<Card
|
||||||
|
title={<span>真人素材项目组</span>}
|
||||||
|
extra={(
|
||||||
|
<Space size={8}>
|
||||||
|
{/* <Button icon={<ReloadOutlined />} onClick={loadProjects} loading={loading} size="small">刷新</Button> */}
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)} size="small">新建项目组</Button>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
style={{ borderRadius: 16, minHeight: 520 }}
|
||||||
|
>
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<Empty description="暂无项目组" />
|
||||||
|
) : (
|
||||||
<PrivatePortraitProjectList items={projects} selectedId={selected?.id} onSelect={setSelected} />
|
<PrivatePortraitProjectList items={projects} selectedId={selected?.id} onSelect={setSelected} />
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} md={17} lg={18}>
|
<Col xs={24} lg={17}>
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<PrivatePortraitProjectDetail project={selected} onDeleted={() => { setSelected(null); loadProjects(); }} onChanged={loadProjects} />
|
<PrivatePortraitProjectDetail project={selected} onDeleted={() => { setSelected(null); loadProjects(); }} onChanged={loadProjects} />
|
||||||
) : (
|
) : (
|
||||||
<Card style={{ borderRadius: 12, textAlign: 'center', color: '#94a3b8' }}>请先创建或选择一个真人素材项目组</Card>
|
<Card style={{ borderRadius: 16, minHeight: 520, textAlign: 'center', color: '#94a3b8' }}>请先创建或选择一个真人素材项目组</Card>
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -178,7 +187,7 @@ const RealPersonLibraryPanel: React.FC = () => {
|
|||||||
{isSuccess ? '认证成功,项目组正在刷新' : '请使用手机扫码完成人脸认证,成功后回到电脑端查看项目组。'}
|
{isSuccess ? '认证成功,项目组正在刷新' : '请使用手机扫码完成人脸认证,成功后回到电脑端查看项目组。'}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
{h5Link && !isSuccess && <Typography.Text copyable style={{ wordBreak: 'break-all' }}>{h5Link}</Typography.Text>}
|
{/* {h5Link && !isSuccess && <Typography.Text copyable style={{ wordBreak: 'break-all' }}>{h5Link}</Typography.Text>} */}
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
App,
|
App,
|
||||||
Button,
|
Button,
|
||||||
@@ -21,12 +21,10 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { UploadFile } from 'antd/es/upload/interface';
|
import type { UploadFile } from 'antd/es/upload/interface';
|
||||||
import {
|
import {
|
||||||
CloudSyncOutlined,
|
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
PictureOutlined,
|
PictureOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
ReloadOutlined,
|
|
||||||
UploadOutlined,
|
UploadOutlined,
|
||||||
VideoCameraOutlined,
|
VideoCameraOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
@@ -36,15 +34,13 @@ import {
|
|||||||
deletePrivatePortraitVirtualAsset,
|
deletePrivatePortraitVirtualAsset,
|
||||||
deletePrivatePortraitVirtualProject,
|
deletePrivatePortraitVirtualProject,
|
||||||
getPrivatePortraitVirtualAssets,
|
getPrivatePortraitVirtualAssets,
|
||||||
getPrivatePortraitVirtualConfig,
|
|
||||||
getPrivatePortraitVirtualProjects,
|
getPrivatePortraitVirtualProjects,
|
||||||
syncPrivatePortraitVirtualAsset,
|
|
||||||
uploadImage,
|
uploadImage,
|
||||||
uploadVideo,
|
uploadVideo,
|
||||||
} from '../../../api';
|
} from '../../../api';
|
||||||
import type { PrivatePortraitAsset, PrivatePortraitConfig, PrivatePortraitProject } from '../../../types';
|
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../../../types';
|
||||||
|
|
||||||
const { Text, Paragraph } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
type AssetTypeFilter = 'Image' | 'Video' | undefined;
|
type AssetTypeFilter = 'Image' | 'Video' | undefined;
|
||||||
|
|
||||||
@@ -53,39 +49,18 @@ const MAX_PRIVATE_VIDEO_DURATION = 15;
|
|||||||
|
|
||||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||||
creating: { label: '本地创建中', color: 'processing' },
|
creating: { label: '本地创建中', color: 'processing' },
|
||||||
Processing: { label: '火山处理中', color: 'processing' },
|
Processing: { label: '入库处理中', color: 'processing' },
|
||||||
Active: { label: '可用于生成', color: 'success' },
|
Active: { label: '入库成功', color: 'success' },
|
||||||
Failed: { label: '入库失败', color: 'error' },
|
Failed: { label: '入库失败', color: 'error' },
|
||||||
local_deleted: { label: '本地已删', color: 'default' },
|
local_deleted: { label: '本地已删', color: 'default' },
|
||||||
remote_deleted: { label: '远端已删', color: 'default' },
|
remote_deleted: { label: '远端已删', color: 'default' },
|
||||||
delete_failed: { label: '远端删除失败', color: 'error' },
|
delete_failed: { label: '远端删除失败', color: 'error' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const assetTypeConfig: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
|
const formatDuration = (value?: number | null) => {
|
||||||
Image: { label: '图片', color: 'green', icon: <PictureOutlined /> },
|
const duration = Number(value || 0);
|
||||||
Video: { label: '视频', color: 'blue', icon: <VideoCameraOutlined /> },
|
if (!Number.isFinite(duration) || duration <= 0) return '-';
|
||||||
};
|
return `${duration.toFixed(duration >= 10 ? 0 : 1)}s`;
|
||||||
|
|
||||||
const formatDateTime = (dateStr?: string | null) => {
|
|
||||||
if (!dateStr) return '-';
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
if (Number.isNaN(date.getTime())) return '-';
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatSize = (size?: number | null) => {
|
|
||||||
const value = Number(size || 0);
|
|
||||||
if (!value) return '-';
|
|
||||||
if (value >= 1024 * 1024 * 1024) return `${(value / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
||||||
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(2)} MB`;
|
|
||||||
if (value >= 1024) return `${(value / 1024).toFixed(2)} KB`;
|
|
||||||
return `${value} B`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildPreviewUrl = (url?: string | null) => {
|
const buildPreviewUrl = (url?: string | null) => {
|
||||||
@@ -154,15 +129,8 @@ const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
|
|||||||
return <Tag color={config?.color || 'default'}>{config?.label || value}</Tag>;
|
return <Tag color={config?.color || 'default'}>{config?.label || value}</Tag>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TypeTag: React.FC<{ type?: string | null }> = ({ type }) => {
|
|
||||||
const value = type || '-';
|
|
||||||
const config = assetTypeConfig[value];
|
|
||||||
return <Tag color={config?.color || 'default'} icon={config?.icon}>{config?.label || value}</Tag>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const VirtualMaterialPanel: React.FC = () => {
|
const VirtualMaterialPanel: React.FC = () => {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const [config, setConfig] = useState<PrivatePortraitConfig | null>(null);
|
|
||||||
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState<string>();
|
const [selectedProjectId, setSelectedProjectId] = useState<string>();
|
||||||
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
|
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
|
||||||
@@ -184,26 +152,13 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
const [previewUrl, setPreviewUrl] = useState('');
|
const [previewUrl, setPreviewUrl] = useState('');
|
||||||
const [previewType, setPreviewType] = useState<'Image' | 'Video'>('Image');
|
const [previewType, setPreviewType] = useState<'Image' | 'Video'>('Image');
|
||||||
const [createForm] = Form.useForm<{ name: string; description?: string }>();
|
const [createForm] = Form.useForm<{ name: string; description?: string }>();
|
||||||
|
const pollingRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const selectedProject = useMemo(
|
const selectedProject = useMemo(
|
||||||
() => projects.find((item) => item.id === selectedProjectId) || null,
|
() => projects.find((item) => item.id === selectedProjectId) || null,
|
||||||
[projects, selectedProjectId],
|
[projects, selectedProjectId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const quotaText = useMemo(() => {
|
|
||||||
if (!config) return '额度加载中';
|
|
||||||
return `已用 ${config.usedAssetCount || 0} / ${config.assetLimit || 0} 个素材,剩余 ${config.remainingAssetCount || 0}`;
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
const loadConfig = async () => {
|
|
||||||
try {
|
|
||||||
const next = await getPrivatePortraitVirtualConfig();
|
|
||||||
setConfig(next);
|
|
||||||
} catch (err: any) {
|
|
||||||
message.error(err?.message || '加载私域素材额度失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadProjects = async () => {
|
const loadProjects = async () => {
|
||||||
setProjectLoading(true);
|
setProjectLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -245,7 +200,7 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const reloadAll = async () => {
|
const reloadAll = async () => {
|
||||||
await Promise.all([loadConfig(), loadProjects()]);
|
await loadProjects();
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -256,6 +211,32 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
if (selectedProjectId) loadAssets(1, assetPageSize);
|
if (selectedProjectId) loadAssets(1, assetPageSize);
|
||||||
}, [selectedProjectId]);
|
}, [selectedProjectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const needsPolling = assets.some(
|
||||||
|
(asset) => asset.status !== 'Failed' && asset.status !== 'Active'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (needsPolling && selectedProjectId) {
|
||||||
|
if (!pollingRef.current) {
|
||||||
|
pollingRef.current = window.setInterval(() => {
|
||||||
|
loadAssets(assetPage, assetPageSize);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (pollingRef.current) {
|
||||||
|
clearInterval(pollingRef.current);
|
||||||
|
pollingRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (pollingRef.current) {
|
||||||
|
clearInterval(pollingRef.current);
|
||||||
|
pollingRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [assets, selectedProjectId, assetPage, assetPageSize]);
|
||||||
|
|
||||||
const handleCreateProject = async () => {
|
const handleCreateProject = async () => {
|
||||||
const values = await createForm.validateFields();
|
const values = await createForm.validateFields();
|
||||||
setCreatingProject(true);
|
setCreatingProject(true);
|
||||||
@@ -308,7 +289,7 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
setUploadOpen(false);
|
setUploadOpen(false);
|
||||||
setFileList([]);
|
setFileList([]);
|
||||||
setAssetName('');
|
setAssetName('');
|
||||||
await Promise.all([loadConfig(), loadProjects(), loadAssets(1, assetPageSize)]);
|
await Promise.all([loadProjects(), loadAssets(1, assetPageSize)]);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
message.error(err?.message || '上传素材失败');
|
message.error(err?.message || '上传素材失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -316,21 +297,11 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSyncAsset = async (assetId: string) => {
|
|
||||||
try {
|
|
||||||
await syncPrivatePortraitVirtualAsset(assetId);
|
|
||||||
message.success('素材状态已刷新');
|
|
||||||
await Promise.all([loadConfig(), loadProjects(), loadAssets(assetPage, assetPageSize)]);
|
|
||||||
} catch (err: any) {
|
|
||||||
message.error(err?.message || '刷新素材状态失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteAsset = async (assetId: string) => {
|
const handleDeleteAsset = async (assetId: string) => {
|
||||||
try {
|
try {
|
||||||
await deletePrivatePortraitVirtualAsset(assetId);
|
await deletePrivatePortraitVirtualAsset(assetId);
|
||||||
message.success('素材已删除,远端删除将异步执行');
|
message.success('素材已删除,远端删除将异步执行');
|
||||||
await Promise.all([loadConfig(), loadProjects(), loadAssets(assetPage, assetPageSize)]);
|
await Promise.all([loadProjects(), loadAssets(assetPage, assetPageSize)]);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
message.error(err?.message || '删除素材失败');
|
message.error(err?.message || '删除素材失败');
|
||||||
}
|
}
|
||||||
@@ -370,36 +341,40 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
style={{ borderRadius: 16, overflow: 'hidden', borderColor: '#eef2f7' }}
|
style={{ borderRadius: 16, overflow: 'hidden', borderColor: '#eef2f7' }}
|
||||||
cover={(
|
cover={(
|
||||||
<div style={{ height: 170, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
|
<div style={{ height: 170, background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
|
||||||
{preview && !isVideo ? (
|
{preview ? (
|
||||||
<img src={preview} alt={asset.name || '虚拟人像素材'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
isVideo && asset.videoCoverUrl ? (
|
||||||
) : preview && isVideo && asset.videoCoverUrl ? (
|
|
||||||
<img src={preview} alt={asset.name || '视频封面'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
<img src={preview} alt={asset.name || '视频封面'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
) : isVideo ? (
|
||||||
|
<video src={preview} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
) : (
|
||||||
|
<img src={preview} alt={asset.name || '虚拟人像素材'} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
)
|
||||||
) : isVideo ? (
|
) : isVideo ? (
|
||||||
<VideoCameraOutlined style={{ fontSize: 42, color: '#64748b' }} />
|
<VideoCameraOutlined style={{ fontSize: 42, color: '#64748b' }} />
|
||||||
) : (
|
) : (
|
||||||
<PictureOutlined style={{ fontSize: 42, color: '#64748b' }} />
|
<PictureOutlined style={{ fontSize: 42, color: '#64748b' }} />
|
||||||
)}
|
)}
|
||||||
{isVideo && <Tag color="blue" style={{ position: 'absolute', left: 10, top: 10 }}>视频</Tag>}
|
{isVideo && <Tag color="blue" style={{ position: 'absolute', left: 10, top: 10 }}>视频</Tag>}
|
||||||
|
{preview && (
|
||||||
<Button size="small" shape="circle" icon={<EyeOutlined />} style={{ position: 'absolute', right: 10, top: 10 }} onClick={() => openPreview(asset)} />
|
<Button size="small" shape="circle" icon={<EyeOutlined />} style={{ position: 'absolute', right: 10, top: 10 }} onClick={() => openPreview(asset)} />
|
||||||
|
)}
|
||||||
|
{isVideo && (
|
||||||
|
<div style={{ position: 'absolute', left: 10, bottom: 10, padding: '2px 6px', borderRadius: 8, background: 'rgba(15,23,42,0.72)', color: '#fff', fontSize: 12 }}>
|
||||||
|
{formatDuration(asset.videoDuration)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||||
<Tooltip title={asset.name || asset.remoteAssetId || asset.id}>
|
<Tooltip title={asset.name || asset.remoteAssetId || asset.id}>
|
||||||
<Text strong ellipsis style={{ display: 'block' }}>{asset.name || asset.remoteAssetId || '未命名素材'}</Text>
|
<div style={{ fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{asset.name || asset.remoteAssetId || '未命名素材'}</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Space wrap size={4}>
|
<Space wrap size={4}>
|
||||||
<TypeTag type={asset.assetType} />
|
|
||||||
<StatusTag status={asset.status} />
|
<StatusTag status={asset.status} />
|
||||||
|
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
<div style={{ color: '#64748b', fontSize: 12, lineHeight: 1.7 }}>
|
|
||||||
<div>大小:{formatSize(asset.fileSize)}</div>
|
|
||||||
<div>轮询:{asset.pollCount || 0} 次</div>
|
|
||||||
<div>创建:{formatDateTime(asset.createdAt)}</div>
|
|
||||||
</div>
|
|
||||||
{asset.errorMessage && <div style={{ color: '#ef4444', fontSize: 12 }}>{asset.errorMessage}</div>}
|
|
||||||
<Space size={6} wrap>
|
<Space size={6} wrap>
|
||||||
<Button size="small" icon={<CloudSyncOutlined />} onClick={() => handleSyncAsset(asset.id)}>同步</Button>
|
|
||||||
<Popconfirm title="确认删除这个虚拟人像素材吗?" onConfirm={() => handleDeleteAsset(asset.id)}>
|
<Popconfirm title="确认删除这个虚拟人像素材吗?" onConfirm={() => handleDeleteAsset(asset.id)}>
|
||||||
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
@@ -411,30 +386,6 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
|
||||||
<Col xs={24} md={8}>
|
|
||||||
<Card style={{ borderRadius: 16, background: 'linear-gradient(135deg,#f5f3ff,#fff)' }}>
|
|
||||||
<Text type="secondary">素材总额度</Text>
|
|
||||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#4f46e5', marginTop: 8 }}>{quotaText}</div>
|
|
||||||
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>真人/虚拟共用,图片/视频共用;音频暂不开放。</Paragraph>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} md={8}>
|
|
||||||
<Card style={{ borderRadius: 16 }}>
|
|
||||||
<Text type="secondary">项目组</Text>
|
|
||||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{projects.length}</div>
|
|
||||||
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>虚拟人像项目会同步创建火山 AIGC Asset Group。</Paragraph>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} md={8}>
|
|
||||||
<Card style={{ borderRadius: 16 }}>
|
|
||||||
<Text type="secondary">当前项目素材</Text>
|
|
||||||
<div style={{ fontSize: 24, fontWeight: 700, color: '#1e293b', marginTop: 8 }}>{selectedProject?.assetCount || 0}</div>
|
|
||||||
<Paragraph style={{ margin: '8px 0 0', color: '#64748b' }}>仅 Active 状态素材可在 AI 创作中引用。</Paragraph>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col xs={24} lg={7}>
|
<Col xs={24} lg={7}>
|
||||||
<Card
|
<Card
|
||||||
@@ -466,13 +417,13 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
<Text strong ellipsis style={{ display: 'block' }}>{project.name}</Text>
|
<Text strong ellipsis style={{ display: 'block' }}>{project.name}</Text>
|
||||||
{project.description && <Text type="secondary" ellipsis style={{ display: 'block', fontSize: 12 }}>{project.description}</Text>}
|
{project.description && <Text type="secondary" ellipsis style={{ display: 'block', fontSize: 12 }}>{project.description}</Text>}
|
||||||
</div>
|
</div>
|
||||||
<StatusTag status={project.status} />
|
{/* <StatusTag status={project.status} /> */}
|
||||||
</Space>
|
</Space>
|
||||||
<Space wrap size={4} style={{ marginTop: 10 }}>
|
<Space wrap size={4} style={{ marginTop: 10 }}>
|
||||||
<Tag>总 {project.assetCount || 0}</Tag>
|
<Tag>总 {project.assetCount || 0}</Tag>
|
||||||
<Tag color="green">图 {project.imageAssetCount || 0}</Tag>
|
<Tag color="green">图 {project.imageAssetCount || 0}</Tag>
|
||||||
<Tag color="blue">视频 {project.videoAssetCount || 0}</Tag>
|
<Tag color="blue">视频 {project.videoAssetCount || 0}</Tag>
|
||||||
<Tag color="success">Active {project.activeAssetCount || 0}</Tag>
|
{/* <Tag color="success">Active {project.activeAssetCount || 0}</Tag> */}
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -488,7 +439,7 @@ const VirtualMaterialPanel: React.FC = () => {
|
|||||||
title={selectedProject ? selectedProject.name : '素材资产'}
|
title={selectedProject ? selectedProject.name : '素材资产'}
|
||||||
extra={(
|
extra={(
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(assetPage, assetPageSize); }} loading={assetLoading}>刷新</Button>
|
{/* <Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(assetPage, assetPageSize); }} loading={assetLoading}>刷新</Button> */}
|
||||||
<Button type="primary" icon={<UploadOutlined />} disabled={!selectedProjectId} onClick={() => setUploadOpen(true)}>上传图片/视频</Button>
|
<Button type="primary" icon={<UploadOutlined />} disabled={!selectedProjectId} onClick={() => setUploadOpen(true)}>上传图片/视频</Button>
|
||||||
{selectedProjectId && (
|
{selectedProjectId && (
|
||||||
<Popconfirm title="确认删除当前虚拟人像项目组吗?" onConfirm={handleDeleteProject}>
|
<Popconfirm title="确认删除当前虚拟人像项目组吗?" onConfirm={handleDeleteProject}>
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ interface PrivatePortraitAssetPickerProps {
|
|||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSelect: (assets: PrivatePortraitSelectableAsset[]) => void;
|
onSelect: (assets: PrivatePortraitSelectableAsset[]) => void;
|
||||||
|
maxImageCount?: number;
|
||||||
|
maxVideoCount?: number;
|
||||||
|
usedImageCount?: number;
|
||||||
|
usedVideoCount?: number;
|
||||||
|
usedVideoDuration?: number;
|
||||||
|
maxVideoDuration?: number;
|
||||||
|
accept?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryMeta: Record<PrivatePortraitLibraryType, { title: string; empty: string; projectError: string; assetError: string; fallbackName: string }> = {
|
const libraryMeta: Record<PrivatePortraitLibraryType, { title: string; empty: string; projectError: string; assetError: string; fallbackName: string }> = {
|
||||||
@@ -68,12 +75,19 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
maxCount = 20,
|
maxCount = 20,
|
||||||
onClose,
|
onClose,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
maxImageCount,
|
||||||
|
maxVideoCount,
|
||||||
|
usedImageCount,
|
||||||
|
usedVideoCount,
|
||||||
|
usedVideoDuration,
|
||||||
|
maxVideoDuration,
|
||||||
|
accept,
|
||||||
}) => {
|
}) => {
|
||||||
const meta = libraryMeta[libraryType];
|
const meta = libraryMeta[libraryType];
|
||||||
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
||||||
const [projectId, setProjectId] = useState<string | undefined>();
|
const [projectId, setProjectId] = useState<string | undefined>();
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [assetType, setAssetType] = useState<AssetTypeFilter>();
|
const [assetType, setAssetType] = useState<AssetTypeFilter>(accept === 'image/*' ? 'Image' : undefined);
|
||||||
const [assets, setAssets] = useState<PrivatePortraitSelectableAsset[]>([]);
|
const [assets, setAssets] = useState<PrivatePortraitSelectableAsset[]>([]);
|
||||||
const [selectedAssets, setSelectedAssets] = useState<Map<string, PrivatePortraitSelectableAsset>>(new Map());
|
const [selectedAssets, setSelectedAssets] = useState<Map<string, PrivatePortraitSelectableAsset>>(new Map());
|
||||||
const [loadingProjects, setLoadingProjects] = useState(false);
|
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||||
@@ -89,8 +103,8 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
const next = res.items || [];
|
const next = res.items || [];
|
||||||
setProjects(next);
|
setProjects(next);
|
||||||
setProjectId((prev) => (prev && next.some((item) => item.id === prev) ? prev : next[0]?.id));
|
setProjectId((prev) => (prev && next.some((item) => item.id === prev) ? prev : next[0]?.id));
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
message.error(err?.message || meta.projectError);
|
message.error((err as { message?: string })?.message || meta.projectError);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingProjects(false);
|
setLoadingProjects(false);
|
||||||
}
|
}
|
||||||
@@ -108,8 +122,8 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
pageSize: 100,
|
pageSize: 100,
|
||||||
});
|
});
|
||||||
setAssets(res.items || []);
|
setAssets(res.items || []);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
message.error(err?.message || meta.assetError);
|
message.error((err as { message?: string })?.message || meta.assetError);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingAssets(false);
|
setLoadingAssets(false);
|
||||||
}
|
}
|
||||||
@@ -117,24 +131,32 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
setTimeout(() => {
|
||||||
setSelectedAssets(new Map());
|
setSelectedAssets(new Map());
|
||||||
setKeyword('');
|
setKeyword('');
|
||||||
setAssetType(undefined);
|
setAssetType(undefined);
|
||||||
setProjectId(undefined);
|
setProjectId(undefined);
|
||||||
setAssets([]);
|
setAssets([]);
|
||||||
loadProjects();
|
loadProjects();
|
||||||
|
}, 0);
|
||||||
}, [open, libraryType]);
|
}, [open, libraryType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
setTimeout(() => {
|
||||||
loadAssets();
|
loadAssets();
|
||||||
}, [open, projectId, assetType]);
|
}, 0);
|
||||||
|
}, [open, projectId, assetType, libraryType]);
|
||||||
|
|
||||||
const toggle = (asset: PrivatePortraitSelectableAsset) => {
|
const toggle = (asset: PrivatePortraitSelectableAsset) => {
|
||||||
if (selectedIds.includes(asset.id)) {
|
if (selectedIds.includes(asset.id)) {
|
||||||
message.info('该素材已添加');
|
message.info('该素材已添加');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (accept === 'image/*' && asset.assetType === 'Video') {
|
||||||
|
message.warning('当前仅支持选择图片素材');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSelectedAssets((prev) => {
|
setSelectedAssets((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
if (next.has(asset.id)) {
|
if (next.has(asset.id)) {
|
||||||
@@ -150,6 +172,20 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isExceeded = useMemo(() => {
|
||||||
|
const selectedImages = Array.from(selectedAssets.values()).filter(a => a.assetType === 'Image').length;
|
||||||
|
const selectedVideos = Array.from(selectedAssets.values()).filter(a => a.assetType === 'Video').length;
|
||||||
|
const selectedVideoDuration = Array.from(selectedAssets.values())
|
||||||
|
.filter(a => a.assetType === 'Video')
|
||||||
|
.reduce((sum, a) => sum + (a.videoDuration || 0), 0);
|
||||||
|
|
||||||
|
const maxAvailableImages = maxImageCount !== undefined && usedImageCount !== undefined ? maxImageCount - usedImageCount : Infinity;
|
||||||
|
const maxAvailableVideos = maxVideoCount !== undefined && usedVideoCount !== undefined ? maxVideoCount - usedVideoCount : Infinity;
|
||||||
|
const maxAvailableDuration = maxVideoDuration !== undefined && usedVideoDuration !== undefined ? maxVideoDuration - usedVideoDuration : Infinity;
|
||||||
|
|
||||||
|
return selectedImages > maxAvailableImages || selectedVideos > maxAvailableVideos || selectedVideoDuration > maxAvailableDuration;
|
||||||
|
}, [selectedAssets, maxImageCount, usedImageCount, maxVideoCount, usedVideoCount, maxVideoDuration, usedVideoDuration]);
|
||||||
|
|
||||||
const confirm = () => {
|
const confirm = () => {
|
||||||
const selected = (Array.from(selectedAssets.values()) as PrivatePortraitSelectableAsset[]).filter((item) => !selectedIds.includes(item.id));
|
const selected = (Array.from(selectedAssets.values()) as PrivatePortraitSelectableAsset[]).filter((item) => !selectedIds.includes(item.id));
|
||||||
if (!selected.length) {
|
if (!selected.length) {
|
||||||
@@ -162,20 +198,54 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={meta.title}
|
title={`${meta.title}`}
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
width={920}
|
width={920}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
footer={[
|
footer={[
|
||||||
<Button key="cancel" onClick={onClose}>取消</Button>,
|
<Button key="cancel" onClick={onClose}>取消</Button>,
|
||||||
<Button key="ok" type="primary" onClick={confirm} style={{ background: '#8b5cf6' }}>
|
<Button key="ok" type="primary" onClick={confirm} disabled={isExceeded} style={{
|
||||||
|
background: isExceeded ? '#94a3b8' : '#8b5cf6',
|
||||||
|
opacity: isExceeded ? 0.6 : 1,
|
||||||
|
cursor: isExceeded ? 'not-allowed' : 'pointer',
|
||||||
|
}}>
|
||||||
添加选中素材({selectedAssets.size})
|
添加选中素材({selectedAssets.size})
|
||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 16, minHeight: 480 }}>
|
{accept !== 'image/*' && (
|
||||||
<div style={{ border: '1px solid #eef0f4', borderRadius: 12, padding: 12, background: '#fafafa' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 20, marginBottom: 16, padding: '12px 16px', background: '#fafafa', borderRadius: 8 }}>
|
||||||
|
{(() => {
|
||||||
|
const selectedImages = Array.from(selectedAssets.values()).filter(a => a.assetType === 'Image').length;
|
||||||
|
const selectedVideos = Array.from(selectedAssets.values()).filter(a => a.assetType === 'Video').length;
|
||||||
|
const selectedVideoDuration = Array.from(selectedAssets.values())
|
||||||
|
.filter(a => a.assetType === 'Video')
|
||||||
|
.reduce((sum, a) => sum + (a.videoDuration || 0), 0);
|
||||||
|
|
||||||
|
const maxAvailableImages = maxImageCount !== undefined && usedImageCount !== undefined ? maxImageCount - usedImageCount : Infinity;
|
||||||
|
const maxAvailableVideos = maxVideoCount !== undefined && usedVideoCount !== undefined ? maxVideoCount - usedVideoCount : Infinity;
|
||||||
|
const maxAvailableDuration = maxVideoDuration !== undefined && usedVideoDuration !== undefined ? maxVideoDuration - usedVideoDuration : Infinity;
|
||||||
|
|
||||||
|
const imageExceeded = selectedImages > maxAvailableImages;
|
||||||
|
const videoExceeded = selectedVideos > maxAvailableVideos;
|
||||||
|
const durationExceeded = selectedVideoDuration > maxAvailableDuration;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span style={{ fontSize: 13, color: imageExceeded ? '#ef4444' : '#64748b', fontWeight: imageExceeded ? 600 : 400 }}>
|
||||||
|
还可选取图片 {selectedImages}/{maxImageCount !== undefined && usedImageCount !== undefined ? maxImageCount - usedImageCount : '-'} 张
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 13, color: videoExceeded || durationExceeded ? '#ef4444' : '#64748b', fontWeight: videoExceeded || durationExceeded ? 600 : 400 }}>
|
||||||
|
视频还可选取 {selectedVideos}/{maxVideoCount !== undefined && usedVideoCount !== undefined ? maxVideoCount - usedVideoCount : '-'} 个({selectedVideoDuration.toFixed(1)}/{maxVideoDuration !== undefined && usedVideoDuration !== undefined ? (maxVideoDuration - usedVideoDuration).toFixed(1) : '-'}秒)
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 16, height: 500, }}>
|
||||||
|
<div style={{ border: '1px solid #eef0f4', borderRadius: 12, padding: 12, background: '#fafafa',overflowY: 'auto', height: "100%" }}>
|
||||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 12 }}>
|
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
<Text strong>项目组</Text>
|
<Text strong>项目组</Text>
|
||||||
<Button size="small" icon={<ReloadOutlined />} onClick={loadProjects} loading={loadingProjects} />
|
<Button size="small" icon={<ReloadOutlined />} onClick={loadProjects} loading={loadingProjects} />
|
||||||
@@ -199,9 +269,9 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
<div style={{ width: '100%' }}>
|
<div style={{ width: '100%' }}>
|
||||||
<Text strong ellipsis style={{ display: 'block' }}>{item.name}</Text>
|
<Text strong ellipsis style={{ display: 'block' }}>{item.name}</Text>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
Active {item.activeAssetCount || 0}
|
{/* {item.activeAssetCount || 0} */}
|
||||||
{typeof item.activeImageAssetCount === 'number' || typeof item.activeVideoAssetCount === 'number'
|
{typeof item.activeImageAssetCount === 'number' || typeof item.activeVideoAssetCount === 'number'
|
||||||
? ` · 图 ${item.activeImageAssetCount || 0} / 视 ${item.activeVideoAssetCount || 0}`
|
? ` 图 ${item.activeImageAssetCount || 0} / 视 ${item.activeVideoAssetCount || 0}`
|
||||||
: ''}
|
: ''}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +281,7 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
</Spin>
|
</Spin>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div style={{overflowY: 'auto', height: "100%" }}>
|
||||||
<Space style={{ width: '100%', marginBottom: 12 }}>
|
<Space style={{ width: '100%', marginBottom: 12 }}>
|
||||||
<Input
|
<Input
|
||||||
allowClear
|
allowClear
|
||||||
@@ -222,12 +292,14 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
onPressEnter={loadAssets}
|
onPressEnter={loadAssets}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear={accept !== 'image/*'}
|
||||||
placeholder="素材类型"
|
placeholder="素材类型"
|
||||||
value={assetType}
|
value={assetType}
|
||||||
onChange={setAssetType}
|
onChange={setAssetType}
|
||||||
style={{ width: 116 }}
|
style={{ width: 116 }}
|
||||||
options={[
|
options={accept === 'image/*' ? [
|
||||||
|
{ value: 'Image', label: '图片' },
|
||||||
|
] : [
|
||||||
{ value: 'Image', label: '图片' },
|
{ value: 'Image', label: '图片' },
|
||||||
{ value: 'Video', label: '视频' },
|
{ value: 'Video', label: '视频' },
|
||||||
]}
|
]}
|
||||||
@@ -239,7 +311,7 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={meta.empty} style={{ marginTop: 120 }} />
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={meta.empty} style={{ marginTop: 120 }} />
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 12, maxHeight: 420, overflowY: 'auto', paddingRight: 4 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 12, maxHeight: 420, overflowY: 'auto', paddingRight: 4 }}>
|
||||||
{assets.map((asset) => {
|
{assets.filter(asset => accept !== 'image/*' || asset.assetType === 'Image').map((asset) => {
|
||||||
const active = checked.has(asset.id) || selectedIds.includes(asset.id);
|
const active = checked.has(asset.id) || selectedIds.includes(asset.id);
|
||||||
const disabled = selectedIds.includes(asset.id);
|
const disabled = selectedIds.includes(asset.id);
|
||||||
const previewUrl = getAssetPreviewUrl(asset);
|
const previewUrl = getAssetPreviewUrl(asset);
|
||||||
@@ -285,7 +357,7 @@ const PrivatePortraitAssetPicker: React.FC<PrivatePortraitAssetPickerProps> = ({
|
|||||||
<div style={{ padding: 10 }}>
|
<div style={{ padding: 10 }}>
|
||||||
<Text strong ellipsis style={{ display: 'block' }}>{asset.name || '未命名素材'}</Text>
|
<Text strong ellipsis style={{ display: 'block' }}>{asset.name || '未命名素材'}</Text>
|
||||||
<Space wrap size={4} style={{ marginTop: 6 }}>
|
<Space wrap size={4} style={{ marginTop: 6 }}>
|
||||||
<Tag color="green">Active</Tag>
|
{/* <Tag color="green">Active</Tag> */}
|
||||||
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
|
<Tag color={isVideo ? 'blue' : 'default'}>{isVideo ? '视频' : '图片'}</Tag>
|
||||||
<Tag>{asset.projectName}</Tag>
|
<Tag>{asset.projectName}</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -218,18 +218,18 @@ const UploadResourceHistoryPanel: React.FC = () => {
|
|||||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#1e293b' }}>{group.generatedDate}</div>
|
<div style={{ fontSize: 18, fontWeight: 700, color: '#1e293b' }}>{group.generatedDate}</div>
|
||||||
<Text type="secondary">共 {group.total} 个素材</Text>
|
<Text type="secondary">共 {group.total} 个素材</Text>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(190px, 1fr))', gap: 16 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'flex-start',}}>
|
||||||
{group.items.map((item) => {
|
{group.items.map((item) => {
|
||||||
const checked = selectedIds.has(item.id);
|
const checked = selectedIds.has(item.id);
|
||||||
return (
|
return (
|
||||||
<div key={item.id} style={{ border: checked ? '2px solid #8b5cf6' : '1px solid #e2e8f0', borderRadius: 16, overflow: 'hidden', background: '#fff', boxShadow: '0 10px 24px rgba(15,23,42,0.06)' }}>
|
<div key={item.id} style={{ width: '17%', minWidth: 240, margin: 16, border: checked ? '2px solid #8b5cf6' : '1px solid #e2e8f0', borderRadius: 16, overflow: 'hidden', background: '#fff', boxShadow: '0 10px 24px rgba(15,23,42,0.06)' }}>
|
||||||
<div style={{ position: 'relative', background: '#f1f5f9' }}>
|
<div style={{ position: 'relative', background: '#f1f5f9' }}>
|
||||||
{renderMedia(item)}
|
{renderMedia(item)}
|
||||||
<Checkbox checked={checked} onChange={() => toggle(item.id)} style={{ position: 'absolute', top: 10, left: 10, background: '#fff', borderRadius: 6, padding: 4 }} />
|
<Checkbox checked={checked} onChange={() => toggle(item.id)} style={{ position: 'absolute', top: 10, left: 10, background: '#fff', borderRadius: 6, padding: 4 }} />
|
||||||
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ position: 'absolute', top: 10, right: 10, margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
|
<Tag color="purple" icon={typeIcon(item.resourceType)} style={{ position: 'absolute', top: 10, right: 10, margin: 0 }}>{typeLabel(item.resourceType)}</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: 12 }}>
|
<div style={{ padding: 12 ,paddingTop: 0}}>
|
||||||
<Text ellipsis title={item.fileName || item.id} style={{ display: 'block', fontWeight: 600, color: '#334155' }}>{item.fileName || item.id}</Text>
|
{/* <Text ellipsis title={item.fileName || item.id} style={{ display: 'block', fontWeight: 600, color: '#334155' }}>{item.fileName || item.id}</Text> */}
|
||||||
<Space size={4} wrap style={{ marginTop: 8 }}>
|
<Space size={4} wrap style={{ marginTop: 8 }}>
|
||||||
<Tag style={{ margin: 0 }}>{item.moduleLabel}</Tag>
|
<Tag style={{ margin: 0 }}>{item.moduleLabel}</Tag>
|
||||||
<Tag style={{ margin: 0 }}>{bytesText(item.fileSizeBytes)}</Tag>
|
<Tag style={{ margin: 0 }}>{bytesText(item.fileSizeBytes)}</Tag>
|
||||||
@@ -246,7 +246,7 @@ const UploadResourceHistoryPanel: React.FC = () => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{group.items.length < group.total && (
|
{group.items.length < group.total && (
|
||||||
<div style={{ textAlign: 'center', marginTop: 14 }}>
|
<div style={{ textAlign: 'left', marginTop: 14 }}>
|
||||||
<Button onClick={() => handleLoadMoreDay(group)}>加载更多</Button>
|
<Button onClick={() => handleLoadMoreDay(group)}>加载更多</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -260,7 +260,11 @@ const UploadResourceHistoryPanel: React.FC = () => {
|
|||||||
<Pagination current={page} pageSize={pageSize} total={totalDays} showSizeChanger pageSizeOptions={[5, 10]} onChange={(nextPage, nextSize) => { setPage(nextPage); setPageSize(nextSize); }} />
|
<Pagination current={page} pageSize={pageSize} total={totalDays} showSizeChanger pageSizeOptions={[5, 10]} onChange={(nextPage, nextSize) => { setPage(nextPage); setPageSize(nextSize); }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal open={!!previewItem} title={previewItem?.fileName || '预览'} footer={null} width={900} centered destroyOnHidden onCancel={() => setPreviewItem(null)}>
|
<Modal open={!!previewItem}
|
||||||
|
// title={previewItem?.fileName || '预览'}
|
||||||
|
title={'预览'}
|
||||||
|
|
||||||
|
footer={null} width={900} centered destroyOnHidden onCancel={() => setPreviewItem(null)}>
|
||||||
{previewItem ? renderMedia(previewItem, 'preview') : null}
|
{previewItem ? renderMedia(previewItem, 'preview') : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -177,7 +177,8 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
|
|||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<Empty description="暂无可复用的历史上传素材" style={{ padding: '48px 0' }} />
|
<Empty description="暂无可复用的历史上传素材" style={{ padding: '48px 0' }} />
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14, minHeight: 260 }}>
|
<div style={{ height: '500px', overflowY: 'auto' }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 14 }}>
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const checked = checkedMap.has(item.id);
|
const checked = checkedMap.has(item.id);
|
||||||
const disabled = selectedIdSet.has(item.id);
|
const disabled = selectedIdSet.has(item.id);
|
||||||
@@ -228,10 +229,7 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16, paddingBottom: 8 }}>
|
||||||
</Spin>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
|
|
||||||
<Pagination
|
<Pagination
|
||||||
current={page}
|
current={page}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
@@ -244,6 +242,9 @@ const UploadResourceHistoryPicker: React.FC<UploadResourceHistoryPickerProps> =
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ const AuthorizationWaitingPage: React.FC = () => {
|
|||||||
<div style={{ textAlign: 'center', padding: '60px 80px', background: '#fff', borderRadius: 16, boxShadow: '0 10px 40px rgba(0,0,0,0.1)' }}>
|
<div style={{ textAlign: 'center', padding: '60px 80px', background: '#fff', borderRadius: 16, boxShadow: '0 10px 40px rgba(0,0,0,0.1)' }}>
|
||||||
<div style={{ width: 80, height: 80, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: '50%', display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '0 auto 24px' }}>
|
<div style={{ width: 80, height: 80, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: '50%', display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '0 auto 24px' }}>
|
||||||
{isChecking && countdown < 10 ? (
|
{isChecking && countdown < 10 ? (
|
||||||
<Spin size="large" tip="加载中" style={{ color: '#fff' }} />
|
<Spin size="large" description="加载中" style={{ color: '#fff' }} />
|
||||||
) : (
|
) : (
|
||||||
<ClockCircleOutlined style={{ fontSize: 40, color: '#fff' }} />
|
<ClockCircleOutlined style={{ fontSize: 40, color: '#fff' }} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
Upload,
|
|
||||||
message,
|
message,
|
||||||
Space,
|
Space,
|
||||||
Typography,
|
Typography,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Modal,
|
Modal,
|
||||||
|
App,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
|
|
||||||
import bg1 from '../assets/bg1.png';
|
import bg1 from '../assets/bg1.png';
|
||||||
@@ -123,6 +123,7 @@ const mockUpload = (file: File): Promise<{ url: string }> => {
|
|||||||
|
|
||||||
const AIChatPage: React.FC = () => {
|
const AIChatPage: React.FC = () => {
|
||||||
// ==================== 状态定义 ====================
|
// ==================== 状态定义 ====================
|
||||||
|
const { message: antdMessage } = App.useApp();
|
||||||
|
|
||||||
const [collapsed, setCollapsed] = useState<boolean>(false);
|
const [collapsed, setCollapsed] = useState<boolean>(false);
|
||||||
|
|
||||||
@@ -322,6 +323,28 @@ const AIChatPage: React.FC = () => {
|
|||||||
const [attachmentPreviewType, setAttachmentPreviewType] = useState<'image' | 'video' | 'audio'>('image');
|
const [attachmentPreviewType, setAttachmentPreviewType] = useState<'image' | 'video' | 'audio'>('image');
|
||||||
const [attachmentPreviewName, setAttachmentPreviewName] = useState<string>('');
|
const [attachmentPreviewName, setAttachmentPreviewName] = useState<string>('');
|
||||||
const attachmentPreviewVideoRef = useRef<HTMLVideoElement>(null);
|
const attachmentPreviewVideoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
|
// 弹窗打开时自动播放视频,关闭时停止
|
||||||
|
useEffect(() => {
|
||||||
|
if (attachmentPreviewVisible && attachmentPreviewType === 'video' && attachmentPreviewVideoRef.current) {
|
||||||
|
const v = attachmentPreviewVideoRef.current;
|
||||||
|
v.currentTime = 0;
|
||||||
|
v.muted = false;
|
||||||
|
const playPromise = v.play();
|
||||||
|
if (playPromise && typeof playPromise.catch === 'function') {
|
||||||
|
playPromise.catch(() => {
|
||||||
|
// 自动播放被阻止时静音重试
|
||||||
|
v.muted = true;
|
||||||
|
v.play().catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (!attachmentPreviewVisible && attachmentPreviewVideoRef.current) {
|
||||||
|
const v = attachmentPreviewVideoRef.current;
|
||||||
|
v.pause();
|
||||||
|
v.muted = true;
|
||||||
|
v.currentTime = 0;
|
||||||
|
}
|
||||||
|
}, [attachmentPreviewVisible, attachmentPreviewType, attachmentPreviewUrl]);
|
||||||
const [playingAudioUrl, setPlayingAudioUrl] = useState<string | null>(null);
|
const [playingAudioUrl, setPlayingAudioUrl] = useState<string | null>(null);
|
||||||
const [audioProgress, setAudioProgress] = useState(0);
|
const [audioProgress, setAudioProgress] = useState(0);
|
||||||
|
|
||||||
@@ -334,9 +357,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
const [showVideoSettingsModal, setShowVideoSettingsModal] = useState(false);
|
const [showVideoSettingsModal, setShowVideoSettingsModal] = useState(false);
|
||||||
const [ratioOptions, setRatioOptions] = useState([]);
|
const [ratioOptions, setRatioOptions] = useState([]);
|
||||||
const [resolutionOptions, setResolutionOptions] = useState([]);
|
const [resolutionOptions, setResolutionOptions] = useState([]);
|
||||||
const [widthandheight, setWidthandHeight] = useState([]);
|
const [currentEngineSupportedSizes, setCurrentEngineSupportedSizes] = useState<Record<string, Record<string, string>>>({});
|
||||||
const [blindex, setBlindex] = useState<any>(0);
|
|
||||||
const [fblindex, setFBlindex] = useState<any>(0);
|
|
||||||
|
|
||||||
const [showEngineModal, setShowEngineModal] = useState(false);
|
const [showEngineModal, setShowEngineModal] = useState(false);
|
||||||
const [showMediaTypeModal, setShowMediaTypeModal] = useState(false);
|
const [showMediaTypeModal, setShowMediaTypeModal] = useState(false);
|
||||||
@@ -389,24 +410,22 @@ const AIChatPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据配置计算积分
|
// 根据配置计算积分(保留两位小数,不做取整)
|
||||||
if (mediaType === 'video') {
|
if (mediaType === 'video') {
|
||||||
// 视频:(秒数 × perSecondCredits + baseCredits) × ratio
|
// 视频:(秒数 × perSecondCredits + baseCredits) × ratio
|
||||||
let total = Math.round((videoDuration * config.perSecondCredits + config.baseCredits) * config.ratio);
|
let total = (videoDuration * config.perSecondCredits + config.baseCredits) * config.ratio;
|
||||||
// 传入视频积分
|
// 传入视频积分
|
||||||
const inputVideoDuration = currentMedia
|
const inputVideoDuration = currentMedia
|
||||||
.filter((m) => m.type === 'video')
|
.filter((m) => m.type === 'video')
|
||||||
.reduce((sum, m) => sum + (m.duration || 0), 0);
|
.reduce((sum, m) => sum + (m.duration || 0), 0);
|
||||||
if (inputVideoDuration > 0) {
|
if (inputVideoDuration > 0) {
|
||||||
const inputVideoCost = Math.round(
|
const inputVideoCost = ((config.inputVideoBaseCredits || 0) + (config.inputVideoPerSecondCredits || 0) * inputVideoDuration) * (config.inputVideoRatio || 1);
|
||||||
((config.inputVideoBaseCredits || 0) + (config.inputVideoPerSecondCredits || 0) * inputVideoDuration) * (config.inputVideoRatio || 1)
|
|
||||||
);
|
|
||||||
total += inputVideoCost;
|
total += inputVideoCost;
|
||||||
}
|
}
|
||||||
return total;
|
return Number(total.toFixed(2));
|
||||||
} else {
|
} else {
|
||||||
// 图片:baseCredits × ratio
|
// 图片:baseCredits × ratio
|
||||||
return config.baseCredits * config.ratio;
|
return Number((config.baseCredits * config.ratio).toFixed(2));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -414,6 +433,8 @@ const AIChatPage: React.FC = () => {
|
|||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const pollingRef = useRef<number | null>(null);
|
const pollingRef = useRef<number | null>(null);
|
||||||
const isFirstLoadRef = useRef<boolean>(true);
|
const isFirstLoadRef = useRef<boolean>(true);
|
||||||
|
const isInitialLoadDone = useRef(false);
|
||||||
|
const lastAutoScrollTime = useRef(0);
|
||||||
|
|
||||||
const currentConversation = conversations.find((c) => c.id === currentConversationId);
|
const currentConversation = conversations.find((c) => c.id === currentConversationId);
|
||||||
|
|
||||||
@@ -426,7 +447,6 @@ const AIChatPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
const [Totalnumber, setTotalnumber] = useState<any>(0);
|
const [Totalnumber, setTotalnumber] = useState<any>(0);
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
const isInitialLoadDone = useRef(false);
|
|
||||||
// ==================== 副作用 ====================
|
// ==================== 副作用 ====================
|
||||||
|
|
||||||
// 滚动到底部的辅助函数
|
// 滚动到底部的辅助函数
|
||||||
@@ -442,6 +462,8 @@ const AIChatPage: React.FC = () => {
|
|||||||
const scrollContainer = scrollContainerRef.current;
|
const scrollContainer = scrollContainerRef.current;
|
||||||
if (!scrollContainer) return;
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
|
lastAutoScrollTime.current = Date.now();
|
||||||
|
|
||||||
const doScroll = () => {
|
const doScroll = () => {
|
||||||
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });
|
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });
|
||||||
};
|
};
|
||||||
@@ -509,9 +531,8 @@ const AIChatPage: React.FC = () => {
|
|||||||
const supportsUR = engine.supportsUniversalReference ?? true;
|
const supportsUR = engine.supportsUniversalReference ?? true;
|
||||||
|
|
||||||
setReferenceMode((prevMode) => {
|
setReferenceMode((prevMode) => {
|
||||||
if (prevMode === 'first_last_frame' && !supportsFLF) {
|
|
||||||
if (supportsUR) return 'universal';
|
if (supportsUR) return 'universal';
|
||||||
} else if (prevMode === 'universal' && !supportsUR) {
|
if (prevMode === 'universal' && !supportsUR) {
|
||||||
if (supportsFLF) return 'first_last_frame';
|
if (supportsFLF) return 'first_last_frame';
|
||||||
}
|
}
|
||||||
return prevMode;
|
return prevMode;
|
||||||
@@ -558,52 +579,51 @@ const AIChatPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let supportedSizes = (data as any).engine?.image?.[0]?.supportedSizes || {};
|
if (data.engine.image && data.engine.image.length > 0) {
|
||||||
let supportedResolutions: string[] = [];
|
const savedImageEngine = data.engine.image.find((e: any) => e.id === countType);
|
||||||
let twokwidth: string[] = [];
|
const targetImageEngine = savedImageEngine || data.engine.image[0];
|
||||||
let fourkwidth: string[] = [];
|
|
||||||
|
|
||||||
for (let key in supportedSizes["2K"]) {
|
const supportedSizes = targetImageEngine.supportedSizes || {};
|
||||||
supportedResolutions.push(key);
|
const resolutionLevels = Object.keys(supportedSizes).sort((a, b) => {
|
||||||
twokwidth.push(supportedSizes["2K"][key]);
|
const levelOrder = { '1K': 0, '2K': 1, '4K': 2 };
|
||||||
}
|
return (levelOrder[a] || 0) - (levelOrder[b] || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryResolution = resolutionLevels[0] || '2K';
|
||||||
|
const supportedResolutions = Object.keys(supportedSizes[primaryResolution] || {});
|
||||||
|
|
||||||
const newRatioOptions = supportedResolutions.map((res: any) => ({
|
const newRatioOptions = supportedResolutions.map((res: any) => ({
|
||||||
value: res,
|
value: res,
|
||||||
label: res,
|
label: res,
|
||||||
}));
|
}));
|
||||||
setRatioOptions(newRatioOptions);
|
setRatioOptions(newRatioOptions);
|
||||||
|
setCurrentEngineSupportedSizes(supportedSizes);
|
||||||
|
setResolutionOptions(resolutionLevels.map((level) => {
|
||||||
|
const match = level.match(/(\d+)K/);
|
||||||
|
const num = match ? parseInt(match[1]) : 1;
|
||||||
|
const labels: Record<number, string> = { 1: '标清', 2: '高清', 4: '超清' };
|
||||||
|
return {
|
||||||
|
value: level,
|
||||||
|
label: `${labels[num] || '高清'} ${level}`,
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
for (let key in supportedSizes["4K"]) {
|
if (supportedSizes[primaryResolution] && supportedSizes[primaryResolution][supportedResolutions[0]]) {
|
||||||
fourkwidth.push(supportedSizes["4K"][key]);
|
const defaultSize = supportedSizes[primaryResolution][supportedResolutions[0]];
|
||||||
|
const [w, h] = defaultSize.split(/[×x]/);
|
||||||
|
setWidth(Number(w));
|
||||||
|
setHeight(Number(h));
|
||||||
|
setSelectedRatio(supportedResolutions[0]);
|
||||||
|
setSelectedResolution(primaryResolution);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newWidthandHeight = [twokwidth, fourkwidth];
|
if (countType === '请选择') {
|
||||||
setWidthandHeight(newWidthandHeight);
|
setCountType(targetImageEngine.id);
|
||||||
setResolutionOptions([
|
}
|
||||||
{ value: '2K', label: '高清 2K' },
|
}
|
||||||
{ value: '4K', label: '超清 4K' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
});
|
});
|
||||||
// getCreditRatios()
|
|
||||||
// .then((data: any) => {
|
|
||||||
// // console.log('积分', data);
|
|
||||||
|
|
||||||
// setCreditRatios(data.video || []);
|
|
||||||
// setCimage(data.image || []);
|
|
||||||
// })
|
|
||||||
// .catch((error) => {
|
|
||||||
// });
|
|
||||||
calculateCredits().then((data: any) => {
|
calculateCredits().then((data: any) => {
|
||||||
// console.log('积分计算', data);
|
// console.log('积分计算', data);
|
||||||
// 保存积分计算数据
|
// 保存积分计算数据
|
||||||
@@ -722,34 +742,56 @@ const AIChatPage: React.FC = () => {
|
|||||||
return () => document.removeEventListener("click", handleClickOutside);
|
return () => document.removeEventListener("click", handleClickOutside);
|
||||||
}, [showVideoSettingsModal]);
|
}, [showVideoSettingsModal]);
|
||||||
|
|
||||||
// 监听 blindex 状态变化
|
// 根据比例和分辨率计算尺寸
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
widthandheight[fblindex] &&
|
|
||||||
widthandheight[fblindex][blindex] !== undefined
|
|
||||||
) {
|
|
||||||
setWidth(Number(widthandheight[fblindex][blindex].substring(0, 4)));
|
|
||||||
setHeight(Number(widthandheight[fblindex][blindex].substring(5)));
|
|
||||||
}
|
|
||||||
}, [blindex]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
widthandheight[fblindex] &&
|
|
||||||
widthandheight[fblindex][blindex] !== undefined
|
|
||||||
) {
|
|
||||||
setWidth(Number(widthandheight[fblindex][blindex].substring(0, 4)));
|
|
||||||
setHeight(Number(widthandheight[fblindex][blindex].substring(5)));
|
|
||||||
}
|
|
||||||
}, [fblindex]);
|
|
||||||
|
|
||||||
// 根据比例计算尺寸
|
|
||||||
const calculateSizeFromRatione = (ratio: string) => {
|
const calculateSizeFromRatione = (ratio: string) => {
|
||||||
setBlindex(ratio);
|
const resolutionLevels = Object.keys(currentEngineSupportedSizes).sort((a, b) => {
|
||||||
|
const levelOrder: Record<string, number> = { '1K': 0, '2K': 1, '4K': 2 };
|
||||||
|
return (levelOrder[a] || 0) - (levelOrder[b] || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetResolution = currentEngineSupportedSizes[selectedResolution] && currentEngineSupportedSizes[selectedResolution][ratio]
|
||||||
|
? selectedResolution
|
||||||
|
: resolutionLevels.find(level => currentEngineSupportedSizes[level] && currentEngineSupportedSizes[level][ratio]) || resolutionLevels[0];
|
||||||
|
|
||||||
|
if (targetResolution && currentEngineSupportedSizes[targetResolution] && currentEngineSupportedSizes[targetResolution][ratio]) {
|
||||||
|
const size = currentEngineSupportedSizes[targetResolution][ratio];
|
||||||
|
const [w, h] = size.split(/[×x]/);
|
||||||
|
setWidth(Number(w));
|
||||||
|
setHeight(Number(h));
|
||||||
|
setSelectedRatio(ratio);
|
||||||
|
setSelectedResolution(targetResolution);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateSizeFromRatiotwo = (ratio: string) => {
|
const calculateSizeFromRatiotwo = (resolution: string) => {
|
||||||
setFBlindex(parseInt(ratio === '2K' ? '0' : '1'));
|
if (!currentEngineSupportedSizes[resolution]) {
|
||||||
|
const resolutionLevels = Object.keys(currentEngineSupportedSizes).sort((a, b) => {
|
||||||
|
const levelOrder: Record<string, number> = { '1K': 0, '2K': 1, '4K': 2 };
|
||||||
|
return (levelOrder[a] || 0) - (levelOrder[b] || 0);
|
||||||
|
});
|
||||||
|
resolution = resolutionLevels[0] || resolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportedRatios = currentEngineSupportedSizes[resolution] ? Object.keys(currentEngineSupportedSizes[resolution]) : [];
|
||||||
|
|
||||||
|
const newRatioOptions = supportedRatios.map((res: any) => ({
|
||||||
|
value: res,
|
||||||
|
label: res,
|
||||||
|
}));
|
||||||
|
setRatioOptions(newRatioOptions);
|
||||||
|
|
||||||
|
const targetRatio = supportedRatios.includes(selectedRatio)
|
||||||
|
? selectedRatio
|
||||||
|
: supportedRatios[0] || '';
|
||||||
|
|
||||||
|
if (targetRatio && currentEngineSupportedSizes[resolution] && currentEngineSupportedSizes[resolution][targetRatio]) {
|
||||||
|
const size = currentEngineSupportedSizes[resolution][targetRatio];
|
||||||
|
const [w, h] = size.split(/[×x]/);
|
||||||
|
setWidth(Number(w));
|
||||||
|
setHeight(Number(h));
|
||||||
|
setSelectedRatio(targetRatio);
|
||||||
|
setSelectedResolution(resolution);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 交换宽高
|
// 交换宽高
|
||||||
@@ -767,6 +809,8 @@ const AIChatPage: React.FC = () => {
|
|||||||
if (mode === 'universal' && !supportsUniversalReference) return;
|
if (mode === 'universal' && !supportsUniversalReference) return;
|
||||||
setReferenceMode(mode);
|
setReferenceMode(mode);
|
||||||
setReferenceModeDropdownVisible(false);
|
setReferenceModeDropdownVisible(false);
|
||||||
|
setCurrentMedia([]);
|
||||||
|
setInputValue('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSwapFrames = () => {
|
const handleSwapFrames = () => {
|
||||||
@@ -807,7 +851,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
setFirstFrame(null);
|
setFirstFrame(null);
|
||||||
setLastFrame(null);
|
setLastFrame(null);
|
||||||
// 显示提示消息
|
// 显示提示消息
|
||||||
message.info('已开启新对话');
|
antdMessage.info('已开启新对话');
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -824,7 +868,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 显示成功提示
|
// 显示成功提示
|
||||||
message.success('对话已删除');
|
antdMessage.success('对话已删除');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -841,12 +885,12 @@ const AIChatPage: React.FC = () => {
|
|||||||
|
|
||||||
if (isFirstLastFrameMode) {
|
if (isFirstLastFrameMode) {
|
||||||
if (!inputValue.trim()) {
|
if (!inputValue.trim()) {
|
||||||
message.warning('请输入内容');
|
antdMessage.warning('请输入内容');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!inputValue.trim() && currentMedia.length === 0) {
|
if (!inputValue.trim() && currentMedia.length === 0) {
|
||||||
message.warning('请输入内容或上传图片/视频');
|
antdMessage.warning('请输入内容或上传图片/视频');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -948,7 +992,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
errorMessage = error.message;
|
errorMessage = error.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
message.error({
|
antdMessage.error({
|
||||||
content: errorMessage,
|
content: errorMessage,
|
||||||
duration: 3,
|
duration: 3,
|
||||||
});
|
});
|
||||||
@@ -1130,16 +1174,16 @@ const AIChatPage: React.FC = () => {
|
|||||||
|
|
||||||
if (mediaType === 'video' && referenceMode === 'first_last_frame') {
|
if (mediaType === 'video' && referenceMode === 'first_last_frame') {
|
||||||
if (!isImage) {
|
if (!isImage) {
|
||||||
message.error('首尾帧模式仅支持上传图片');
|
antdMessage.error('首尾帧模式仅支持上传图片');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (file.size / 1024 / 1024 > 10) {
|
if (file.size / 1024 / 1024 > 10) {
|
||||||
message.error('图片大小不能超过10MB');
|
antdMessage.error('图片大小不能超过10MB');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const effectiveUploadTarget = frameTarget || uploadTarget;
|
const effectiveUploadTarget = frameTarget || uploadTarget;
|
||||||
if (!effectiveUploadTarget) {
|
if (!effectiveUploadTarget) {
|
||||||
message.error('请选择首帧或尾帧上传位置');
|
antdMessage.error('请选择首帧或尾帧上传位置');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1157,9 +1201,9 @@ const AIChatPage: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
setLastFrame(mediaRef);
|
setLastFrame(mediaRef);
|
||||||
}
|
}
|
||||||
message.success('图片上传成功');
|
antdMessage.success('图片上传成功');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('上传失败');
|
antdMessage.error('上传失败');
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
setUploadTarget(null);
|
setUploadTarget(null);
|
||||||
@@ -1170,32 +1214,32 @@ const AIChatPage: React.FC = () => {
|
|||||||
const isAudio = file.type.startsWith('audio/');
|
const isAudio = file.type.startsWith('audio/');
|
||||||
|
|
||||||
if (!isImage && !isVideo && !isAudio) {
|
if (!isImage && !isVideo && !isAudio) {
|
||||||
message.error('仅支持图片、视频或音频文件');
|
antdMessage.error('仅支持图片、视频或音频文件');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAudio) {
|
if (isAudio) {
|
||||||
const audioExt = file.name.split('.').pop()?.toLowerCase();
|
const audioExt = file.name.split('.').pop()?.toLowerCase();
|
||||||
if (!['wav', 'mp3'].includes(audioExt || '')) {
|
if (!['wav', 'mp3'].includes(audioExt || '')) {
|
||||||
message.error('音频仅支持wav和mp3格式');
|
antdMessage.error('音频仅支持wav和mp3格式');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaType === 'image' && (isVideo || isAudio)) {
|
if (mediaType === 'image' && (isVideo || isAudio)) {
|
||||||
message.error('图片模式仅支持上传图片');
|
antdMessage.error('图片模式仅支持上传图片');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAudio && mediaType !== 'video') {
|
if (isAudio && mediaType !== 'video') {
|
||||||
message.error('仅视频模式支持上传音频');
|
antdMessage.error('仅视频模式支持上传音频');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxMB = isVideo ? 100 : (isAudio ? 50 : 10);
|
const maxMB = isVideo ? 100 : (isAudio ? 50 : 10);
|
||||||
const fileTypeText = isVideo ? '视频' : (isAudio ? '音频' : '图片');
|
const fileTypeText = isVideo ? '视频' : (isAudio ? '音频' : '图片');
|
||||||
if (file.size / 1024 / 1024 > maxMB) {
|
if (file.size / 1024 / 1024 > maxMB) {
|
||||||
message.error(`${fileTypeText}大小不能超过${maxMB}MB`);
|
antdMessage.error(`${fileTypeText}大小不能超过${maxMB}MB`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1205,30 +1249,30 @@ const AIChatPage: React.FC = () => {
|
|||||||
const { width, height } = await getImageDimensions(file);
|
const { width, height } = await getImageDimensions(file);
|
||||||
const error = validateImageDimensions(width, height);
|
const error = validateImageDimensions(width, height);
|
||||||
if (error) {
|
if (error) {
|
||||||
message.error(error);
|
antdMessage.error(error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
message.error('无法读取图片尺寸,请检查文件是否损坏');
|
antdMessage.error('无法读取图片尺寸,请检查文件是否损坏');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageCount = currentMedia.filter((m) => m.type === 'image').length;
|
const imageCount = currentMedia.filter((m) => m.type === 'image').length;
|
||||||
if (isImage && imageCount >= maxImage) {
|
if (isImage && imageCount >= maxImage) {
|
||||||
message.error(`该引擎最多上传${maxImage}张图片`);
|
antdMessage.error(`该引擎最多上传${maxImage}张图片`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoCount = currentMedia.filter((m) => m.type === 'video').length;
|
const videoCount = currentMedia.filter((m) => m.type === 'video').length;
|
||||||
if (isVideo && videoCount >= maxVideo) {
|
if (isVideo && videoCount >= maxVideo) {
|
||||||
message.error(`该引擎最多上传${maxVideo}个视频`);
|
antdMessage.error(`该引擎最多上传${maxVideo}个视频`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioCount = currentMedia.filter((m) => m.type === 'audio').length;
|
const audioCount = currentMedia.filter((m) => m.type === 'audio').length;
|
||||||
if (isAudio && audioCount >= maxAudio) {
|
if (isAudio && audioCount >= maxAudio) {
|
||||||
message.error(`该引擎最多上传${maxAudio}个音频`);
|
antdMessage.error(`该引擎最多上传${maxAudio}个音频`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1238,25 +1282,25 @@ const AIChatPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
videoDuration = await getVideoDuration(file);
|
videoDuration = await getVideoDuration(file);
|
||||||
if (videoDuration < 2) {
|
if (videoDuration < 2) {
|
||||||
message.error('视频素材最短不能少于 2 秒');
|
antdMessage.error('视频素材最短不能少于 2 秒');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const existingVideoDuration = currentMedia
|
const existingVideoDuration = currentMedia
|
||||||
.filter((m) => m.type === 'video')
|
.filter((m) => m.type === 'video')
|
||||||
.reduce((sum, m) => sum + (m.duration || 0), 0);
|
.reduce((sum, m) => sum + (m.duration || 0), 0);
|
||||||
if (existingVideoDuration + videoDuration > 15) {
|
if (existingVideoDuration + videoDuration > 15) {
|
||||||
message.error(`所有视频素材总时长不能超过 15 秒,当前 ${(existingVideoDuration + videoDuration).toFixed(1)} 秒`);
|
antdMessage.error(`所有视频素材总时长不能超过 15 秒,当前 ${(existingVideoDuration + videoDuration).toFixed(1)} 秒`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 视频尺寸校验
|
// 视频尺寸校验
|
||||||
const { width, height } = await getVideoDimensions(file);
|
const { width, height } = await getVideoDimensions(file);
|
||||||
const error = validateVideoDimensions(width, height);
|
const error = validateVideoDimensions(width, height);
|
||||||
if (error) {
|
if (error) {
|
||||||
message.error(error);
|
antdMessage.error(error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
message.error('无法获取视频信息,请检查文件是否损坏');
|
antdMessage.error('无法获取视频信息,请检查文件是否损坏');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1268,18 +1312,18 @@ const AIChatPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
audioDuration = await getAudioDuration(file);
|
audioDuration = await getAudioDuration(file);
|
||||||
if (audioDuration < 2) {
|
if (audioDuration < 2) {
|
||||||
message.error('音频素材最短不能少于 2 秒');
|
antdMessage.error('音频素材最短不能少于 2 秒');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const existingAudioDuration = currentMedia
|
const existingAudioDuration = currentMedia
|
||||||
.filter((m) => m.type === 'audio')
|
.filter((m) => m.type === 'audio')
|
||||||
.reduce((sum, m) => sum + (m.duration || 0), 0);
|
.reduce((sum, m) => sum + (m.duration || 0), 0);
|
||||||
if (existingAudioDuration + audioDuration > 15) {
|
if (existingAudioDuration + audioDuration > 15) {
|
||||||
message.error(`所有音频素材总时长不能超过 15 秒,当前 ${(existingAudioDuration + audioDuration).toFixed(1)} 秒`);
|
antdMessage.error(`所有音频素材总时长不能超过 15 秒,当前 ${(existingAudioDuration + audioDuration).toFixed(1)} 秒`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
message.error('无法获取音频信息,请检查文件是否损坏');
|
antdMessage.error('无法获取音频信息,请检查文件是否损坏');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1301,7 +1345,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
const labels = generateMediaLabels(newList);
|
const labels = generateMediaLabels(newList);
|
||||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('上传失败');
|
antdMessage.error('上传失败');
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
@@ -1315,20 +1359,20 @@ const AIChatPage: React.FC = () => {
|
|||||||
const isAudio = file.type.startsWith('audio/');
|
const isAudio = file.type.startsWith('audio/');
|
||||||
|
|
||||||
if (!isImage && !isVideo && !isAudio) {
|
if (!isImage && !isVideo && !isAudio) {
|
||||||
message.error('仅支持图片、视频或音频文件');
|
antdMessage.error('仅支持图片、视频或音频文件');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxMB = isVideo ? 100 : (isAudio ? 50 : 10);
|
const maxMB = isVideo ? 100 : (isAudio ? 50 : 10);
|
||||||
if (file.size / 1024 / 1024 > maxMB) {
|
if (file.size / 1024 / 1024 > maxMB) {
|
||||||
message.error(`${isVideo ? '视频' : (isAudio ? '音频' : '图片')}大小不能超过${maxMB}MB`);
|
antdMessage.error(`${isVideo ? '视频' : (isAudio ? '音频' : '图片')}大小不能超过${maxMB}MB`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAudio) {
|
if (isAudio) {
|
||||||
const audioExt = file.name.split('.').pop()?.toLowerCase();
|
const audioExt = file.name.split('.').pop()?.toLowerCase();
|
||||||
if (!['wav', 'mp3'].includes(audioExt || '')) {
|
if (!['wav', 'mp3'].includes(audioExt || '')) {
|
||||||
message.error('音频仅支持wav和mp3格式');
|
antdMessage.error('音频仅支持wav和mp3格式');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1339,7 +1383,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
videoDuration = await getVideoDuration(file);
|
videoDuration = await getVideoDuration(file);
|
||||||
if (videoDuration < 2) {
|
if (videoDuration < 2) {
|
||||||
message.error('视频素材最短不能少于 2 秒');
|
antdMessage.error('视频素材最短不能少于 2 秒');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const latestMedia = useAppStore.getState().currentMedia;
|
const latestMedia = useAppStore.getState().currentMedia;
|
||||||
@@ -1347,11 +1391,11 @@ const AIChatPage: React.FC = () => {
|
|||||||
.filter((m) => m.type === 'video')
|
.filter((m) => m.type === 'video')
|
||||||
.reduce((sum, m) => sum + (m.duration || 0), 0);
|
.reduce((sum, m) => sum + (m.duration || 0), 0);
|
||||||
if (existingVideoDuration + videoDuration > 15) {
|
if (existingVideoDuration + videoDuration > 15) {
|
||||||
message.error(`所有视频素材总时长不能超过 15 秒,当前 ${(existingVideoDuration + videoDuration).toFixed(1)} 秒`);
|
antdMessage.error(`所有视频素材总时长不能超过 15 秒,当前 ${(existingVideoDuration + videoDuration).toFixed(1)} 秒`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
message.error('无法获取视频信息,请检查文件是否损坏');
|
antdMessage.error('无法获取视频信息,请检查文件是否损坏');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1360,7 +1404,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
audioDuration = await getAudioDuration(file);
|
audioDuration = await getAudioDuration(file);
|
||||||
if (audioDuration < 2) {
|
if (audioDuration < 2) {
|
||||||
message.error('音频素材最短不能少于 2 秒');
|
antdMessage.error('音频素材最短不能少于 2 秒');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const latestMedia = useAppStore.getState().currentMedia;
|
const latestMedia = useAppStore.getState().currentMedia;
|
||||||
@@ -1368,11 +1412,11 @@ const AIChatPage: React.FC = () => {
|
|||||||
.filter((m) => m.type === 'audio')
|
.filter((m) => m.type === 'audio')
|
||||||
.reduce((sum, m) => sum + (m.duration || 0), 0);
|
.reduce((sum, m) => sum + (m.duration || 0), 0);
|
||||||
if (existingAudioDuration + audioDuration > 15) {
|
if (existingAudioDuration + audioDuration > 15) {
|
||||||
message.error(`所有音频素材总时长不能超过 15 秒,当前 ${(existingAudioDuration + audioDuration).toFixed(1)} 秒`);
|
antdMessage.error(`所有音频素材总时长不能超过 15 秒,当前 ${(existingAudioDuration + audioDuration).toFixed(1)} 秒`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
message.error('无法获取音频信息,请检查文件是否损坏');
|
antdMessage.error('无法获取音频信息,请检查文件是否损坏');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1402,7 +1446,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
...(pendingMedia.duration !== undefined && { duration: pendingMedia.duration }),
|
...(pendingMedia.duration !== undefined && { duration: pendingMedia.duration }),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('上传失败');
|
antdMessage.error('上传失败');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1430,7 +1474,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (successCount > 0) {
|
if (successCount > 0) {
|
||||||
message.success(`成功上传${successCount}个文件${failCount > 0 ? `,${failCount}个文件上传失败` : ''}`);
|
antdMessage.success(`成功上传${successCount}个文件${failCount > 0 ? `,${failCount}个文件上传失败` : ''}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1444,12 +1488,12 @@ const AIChatPage: React.FC = () => {
|
|||||||
if (!incoming.length) return false;
|
if (!incoming.length) return false;
|
||||||
|
|
||||||
if (mediaType === 'image' && incoming.some((item) => item.type !== 'image')) {
|
if (mediaType === 'image' && incoming.some((item) => item.type !== 'image')) {
|
||||||
message.error('图片模式仅支持添加图片素材');
|
antdMessage.error('图片模式仅支持添加图片素材');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (incoming.some((item) => item.type === 'audio') && mediaType !== 'video') {
|
if (incoming.some((item) => item.type === 'audio') && mediaType !== 'video') {
|
||||||
message.error('仅视频模式支持添加音频素材');
|
antdMessage.error('仅视频模式支持添加音频素材');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1461,15 +1505,15 @@ const AIChatPage: React.FC = () => {
|
|||||||
const incomingAudioCount = incoming.filter((m) => m.type === 'audio').length;
|
const incomingAudioCount = incoming.filter((m) => m.type === 'audio').length;
|
||||||
|
|
||||||
if (imageCount + incomingImageCount > maxImage) {
|
if (imageCount + incomingImageCount > maxImage) {
|
||||||
message.error(`该引擎最多上传${maxImage}张图片,当前还能添加 ${Math.max(0, maxImage - imageCount)} 张`);
|
antdMessage.error(`该引擎最多上传${maxImage}张图片,当前还能添加 ${Math.max(0, maxImage - imageCount)} 张`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (videoCount + incomingVideoCount > maxVideo) {
|
if (videoCount + incomingVideoCount > maxVideo) {
|
||||||
message.error(`该引擎最多上传${maxVideo}个视频,当前还能添加 ${Math.max(0, maxVideo - videoCount)} 个`);
|
antdMessage.error(`该引擎最多上传${maxVideo}个视频,当前还能添加 ${Math.max(0, maxVideo - videoCount)} 个`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (audioCount + incomingAudioCount > maxAudio) {
|
if (audioCount + incomingAudioCount > maxAudio) {
|
||||||
message.error(`该引擎最多上传${maxAudio}个音频,当前还能添加 ${Math.max(0, maxAudio - audioCount)} 个`);
|
antdMessage.error(`该引擎最多上传${maxAudio}个音频,当前还能添加 ${Math.max(0, maxAudio - audioCount)} 个`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1477,28 +1521,28 @@ const AIChatPage: React.FC = () => {
|
|||||||
if (item.type !== 'video') continue;
|
if (item.type !== 'video') continue;
|
||||||
const duration = Number(item.duration);
|
const duration = Number(item.duration);
|
||||||
if (!Number.isFinite(duration) || duration <= 0) {
|
if (!Number.isFinite(duration) || duration <= 0) {
|
||||||
message.error(`${item.name || '视频素材'}缺少视频秒数,不能用于 AI 创作`);
|
antdMessage.error(`${item.name || '视频素材'}缺少视频秒数,不能用于 AI 创作`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (duration < 2) {
|
if (duration < 2) {
|
||||||
message.error(`${item.name || '视频素材'}最短不能少于 2 秒`);
|
antdMessage.error(`${item.name || '视频素材'}最短不能少于 2 秒`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (duration > 15) {
|
if (duration > 15) {
|
||||||
message.error(`${item.name || '视频素材'}最长不能超过 15 秒`);
|
antdMessage.error(`${item.name || '视频素材'}最长不能超过 15 秒`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalVideoDuration = getMediaDurationTotal(baseMedia, 'video') + getMediaDurationTotal(incoming, 'video');
|
const totalVideoDuration = getMediaDurationTotal(baseMedia, 'video') + getMediaDurationTotal(incoming, 'video');
|
||||||
if (totalVideoDuration > 15) {
|
if (totalVideoDuration > 15) {
|
||||||
message.error(`所有视频素材总时长不能超过 15 秒,当前 ${totalVideoDuration.toFixed(1)} 秒`);
|
antdMessage.error(`所有视频素材总时长不能超过 15 秒,当前 ${totalVideoDuration.toFixed(1)} 秒`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalAudioDuration = getMediaDurationTotal(baseMedia, 'audio') + getMediaDurationTotal(incoming, 'audio');
|
const totalAudioDuration = getMediaDurationTotal(baseMedia, 'audio') + getMediaDurationTotal(incoming, 'audio');
|
||||||
if (totalAudioDuration > 15) {
|
if (totalAudioDuration > 15) {
|
||||||
message.error(`所有音频素材总时长不能超过 15 秒,当前 ${totalAudioDuration.toFixed(1)} 秒`);
|
antdMessage.error(`所有音频素材总时长不能超过 15 秒,当前 ${totalAudioDuration.toFixed(1)} 秒`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1511,11 +1555,11 @@ const AIChatPage: React.FC = () => {
|
|||||||
const refType = (media?.type || item.resourceType) as 'image' | 'video' | 'audio';
|
const refType = (media?.type || item.resourceType) as 'image' | 'video' | 'audio';
|
||||||
const url = media?.url || item.resourceUrl || item.displayUrl || item.previewUrl || '';
|
const url = media?.url || item.resourceUrl || item.displayUrl || item.previewUrl || '';
|
||||||
if (!url) {
|
if (!url) {
|
||||||
message.error(`${item.fileName || item.id} 缺少素材地址,不能用于 AI 创作`);
|
antdMessage.error(`${item.fileName || item.id} 缺少素材地址,不能用于 AI 创作`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (refType === 'audio' && mediaType !== 'video') {
|
if (refType === 'audio' && mediaType !== 'video') {
|
||||||
message.error('仅视频模式支持添加音频素材');
|
antdMessage.error('仅视频模式支持添加音频素材');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const duration = Number(media?.duration ?? item.durationSeconds);
|
const duration = Number(media?.duration ?? item.durationSeconds);
|
||||||
@@ -1544,12 +1588,12 @@ const AIChatPage: React.FC = () => {
|
|||||||
const newList = [...latestMedia, ...normalized];
|
const newList = [...latestMedia, ...normalized];
|
||||||
const labels = generateMediaLabels(newList);
|
const labels = generateMediaLabels(newList);
|
||||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||||
message.success(`已添加 ${normalized.length} 个历史上传素材参考`);
|
antdMessage.success(`已添加 ${normalized.length} 个历史上传素材参考`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizePrivatePortraitAsset = (asset: PrivatePortraitSelectableAsset): MediaReference | null => {
|
const normalizePrivatePortraitAsset = (asset: PrivatePortraitSelectableAsset): MediaReference | null => {
|
||||||
if (asset.assetType === 'Audio') {
|
if (asset.assetType === 'Audio') {
|
||||||
message.error('音频私域素材暂不支持用于 AI 创作');
|
antdMessage.error('音频私域素材暂不支持用于 AI 创作');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1587,7 +1631,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
const newList = [...latestMedia, ...normalized];
|
const newList = [...latestMedia, ...normalized];
|
||||||
const labels = generateMediaLabels(newList);
|
const labels = generateMediaLabels(newList);
|
||||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||||
message.success(`已添加 ${normalized.length} 个${privateAssetPickerLibraryType === 'aigc_virtual' ? '虚拟' : '真人'}素材参考`);
|
antdMessage.success(`已添加 ${normalized.length} 个${privateAssetPickerLibraryType === 'aigc_virtual' ? '虚拟' : '真人'}素材参考`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildPreviewUrl = (url: string) => {
|
const buildPreviewUrl = (url: string) => {
|
||||||
@@ -1748,7 +1792,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
const composerPlaceholder = mediaType === 'image'
|
const composerPlaceholder = mediaType === 'image'
|
||||||
? '输入画面描述,或上传图片参考风格、构图、主体与光影。例:@图片1 参考色调,生成一张简洁高级的产品海报。'
|
? '输入画面描述,或上传图片参考风格、构图、主体与光影。例:@图片1 参考色调,生成一张简洁高级的产品海报。'
|
||||||
: isFirstLastFrameComposer
|
: isFirstLastFrameComposer
|
||||||
? '描述首帧到尾帧的主体动作、镜头运动、节奏与转场。例:从首帧自然推进到尾帧,产品居中突出,动作平滑连贯。'
|
? '首帧尾帧可以不传,如需传入建议描述首帧到尾帧的主体动作、镜头运动、节奏与转场。例:从首帧自然推进到尾帧,产品居中突出,动作平滑连贯。'
|
||||||
: '上传最多12个参考素材,输入文字或 @ 引用参考内容,自由组合图、文、视频。例:@图片1 模仿 @视频1 的动作。';
|
: '上传最多12个参考素材,输入文字或 @ 引用参考内容,自由组合图、文、视频。例:@图片1 模仿 @视频1 的动作。';
|
||||||
const composerHelperText = mediaType === 'image'
|
const composerHelperText = mediaType === 'image'
|
||||||
? '图片参考 · 适合海报、产品图、场景图与风格图生成'
|
? '图片参考 · 适合海报、产品图、场景图与风格图生成'
|
||||||
@@ -1950,27 +1994,21 @@ const AIChatPage: React.FC = () => {
|
|||||||
background: '#fff',
|
background: '#fff',
|
||||||
// borderRadius: 22,
|
// borderRadius: 22,
|
||||||
}}
|
}}
|
||||||
>
|
onScroll={(e) => {
|
||||||
{/* 加载更多按钮 - 在列表顶部 */}
|
const target = e.currentTarget;
|
||||||
<div style={{ padding: '8px 0', textAlign: 'center', marginBottom: 16 }}>
|
const now = Date.now();
|
||||||
{gen_list.length >= Totalnumber ? (
|
const isAutoScroll = now - lastAutoScrollTime.current < 500;
|
||||||
<span style={{ fontSize: 12, color: '#98a2b3' }}>消息全部加载</span>
|
if (!isAutoScroll && isInitialLoadDone.current && target.scrollTop <= 50 && !isLoadingMore && gen_list.length < Totalnumber) {
|
||||||
) : (
|
handleLoadMore();
|
||||||
<button
|
}
|
||||||
onClick={handleLoadMore}
|
|
||||||
disabled={isLoadingMore}
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#8b5cf6',
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
cursor: isLoadingMore ? 'not-allowed' : 'pointer',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isLoadingMore ? '加载中...' : '加载更多'}
|
{/* 加载更多提示 */}
|
||||||
</button>
|
{isLoadingMore && (
|
||||||
)}
|
<div style={{ padding: '8px 0', textAlign: 'center', marginBottom: 16 }}>
|
||||||
|
<span style={{ fontSize: 12, color: '#8b5cf6' }}>加载中...</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* 遍历消息列表 */}
|
{/* 遍历消息列表 */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const msgApi = message;
|
const msgApi = message;
|
||||||
@@ -2270,7 +2308,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
<span style={{
|
<span style={{
|
||||||
// background: 'rgba(139, 92, 246, 0.08)',
|
// background: 'rgba(139, 92, 246, 0.08)',
|
||||||
borderRadius: 16, color: '#667085'
|
borderRadius: 16, color: '#667085'
|
||||||
}}>{msg.genType === 'image' ? `${msg.imageProportion || ''} · ${msg.imagePx || ''} · ${msg.imageSize || ''}` : `${msg.duration || ''}s · ${msg.aspectRatio || ''} · ${msg.resolution || ''}`}</span>
|
}}>{msg.genType === 'image' ? `${msg.imageProportion || ''} · ${msg.imagePx || ''} · ${msg.imageSize || ''}` : `${msg.duration || ''}秒 · ${msg.aspectRatio || ''} · ${msg.resolution || ''}`}</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
// background: 'rgba(164, 91, 91, 0.08)',
|
// background: 'rgba(164, 91, 91, 0.08)',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -2541,7 +2579,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
{/* 首帧 */}
|
{/* 首帧 */}
|
||||||
<div style={{ position: 'relative', flex: 1, minWidth: 0, height: 100, borderRadius: 12, border: '1px solid #E7EAF0', background: '#ffffff', boxShadow: '0 6px 16px rgba(47, 52, 64, 0.04)', overflow: 'hidden' }}>
|
<div style={{ position: 'relative', flex: 1, minWidth: 0, height: 100, borderRadius: 12, border: '1px solid #E7EAF0', background: '#ffffff', boxShadow: '0 6px 16px rgba(47, 52, 64, 0.04)', overflow: 'hidden' }}>
|
||||||
<div style={{ position: 'absolute', top: 8, left: 10, right: 10, display: 'flex', alignItems: 'center', justifyContent: 'space-between', zIndex: 2 }}>
|
<div style={{ position: 'absolute', top: 8, left: 10, right: 10, display: 'flex', alignItems: 'center', justifyContent: 'space-between', zIndex: 2 }}>
|
||||||
<span style={{ fontSize: 11, fontWeight: 600, color: '#8b5cf6', lineHeight: 1 }}>首帧</span>
|
<span style={{ fontSize: 11, fontWeight: 600, color: '#8b5cf6', lineHeight: 1 }}>首帧(可选)</span>
|
||||||
</div>
|
</div>
|
||||||
{firstFrame ? (
|
{firstFrame ? (
|
||||||
<div style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64 }}>
|
<div style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64 }}>
|
||||||
@@ -2564,7 +2602,45 @@ const AIChatPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Upload accept="image/*" showUploadList={false} beforeUpload={(file) => handleUpload(file, 'first')}>
|
<UploadSelector
|
||||||
|
accept="image/*"
|
||||||
|
multiple={false}
|
||||||
|
onLocalSelect={(files) => {
|
||||||
|
if (files.length > 0) {
|
||||||
|
handleUpload(files[0], 'first');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onHistorySelect={(items) => {
|
||||||
|
if (items.length > 0) {
|
||||||
|
const item = items[0];
|
||||||
|
const mediaRef: MediaReference = {
|
||||||
|
name: item.fileName || '',
|
||||||
|
type: 'image',
|
||||||
|
url: item.resourceUrl || item.previewUrl || item.displayUrl || '',
|
||||||
|
role: 'first_frame',
|
||||||
|
label: '',
|
||||||
|
};
|
||||||
|
setFirstFrame(mediaRef);
|
||||||
|
antdMessage.success('成功添加首帧');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPortraitSelect={(assets) => {
|
||||||
|
if (assets.length > 0) {
|
||||||
|
const asset = assets[0];
|
||||||
|
const previewUrl = asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.providerUrl || '';
|
||||||
|
const mediaRef: MediaReference = {
|
||||||
|
name: asset.name || '首帧图片',
|
||||||
|
type: 'image',
|
||||||
|
url: previewUrl,
|
||||||
|
role: 'first_frame',
|
||||||
|
label: '',
|
||||||
|
};
|
||||||
|
setFirstFrame(mediaRef);
|
||||||
|
antdMessage.success('成功添加首帧');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
uploading={uploading}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64, borderRadius: 8, border: '1px dashed rgba(139, 92, 246, 0.28)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'all 0.25s ease', backgroundColor: 'rgba(139, 92, 246, 0.04)', flexDirection: 'column', gap: 4 }}
|
style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64, borderRadius: 8, border: '1px dashed rgba(139, 92, 246, 0.28)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'all 0.25s ease', backgroundColor: 'rgba(139, 92, 246, 0.04)', flexDirection: 'column', gap: 4 }}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#a78bfa'; e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.08)'; }}
|
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#a78bfa'; e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.08)'; }}
|
||||||
@@ -2573,7 +2649,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
<PlusOutlined style={{ fontSize: 18, color: '#8b5cf6' }} />
|
<PlusOutlined style={{ fontSize: 18, color: '#8b5cf6' }} />
|
||||||
<span style={{ fontSize: 12, color: '#8b5cf6', fontWeight: 500 }}>上传首帧</span>
|
<span style={{ fontSize: 12, color: '#8b5cf6', fontWeight: 500 }}>上传首帧</span>
|
||||||
</div>
|
</div>
|
||||||
</Upload>
|
</UploadSelector>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2616,7 +2692,45 @@ const AIChatPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
firstFrame ? (
|
firstFrame ? (
|
||||||
<Upload accept="image/*" showUploadList={false} beforeUpload={(file) => handleUpload(file, 'last')}>
|
<UploadSelector
|
||||||
|
accept="image/*"
|
||||||
|
multiple={false}
|
||||||
|
onLocalSelect={(files) => {
|
||||||
|
if (files.length > 0) {
|
||||||
|
handleUpload(files[0], 'last');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onHistorySelect={(items) => {
|
||||||
|
if (items.length > 0) {
|
||||||
|
const item = items[0];
|
||||||
|
const mediaRef: MediaReference = {
|
||||||
|
name: item.fileName || '',
|
||||||
|
type: 'image',
|
||||||
|
url: item.resourceUrl || item.previewUrl || item.displayUrl || '',
|
||||||
|
role: 'last_frame',
|
||||||
|
label: '',
|
||||||
|
};
|
||||||
|
setLastFrame(mediaRef);
|
||||||
|
antdMessage.success('成功添加尾帧');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPortraitSelect={(assets) => {
|
||||||
|
if (assets.length > 0) {
|
||||||
|
const asset = assets[0];
|
||||||
|
const previewUrl = asset.previewUrl || asset.displayUrl || asset.videoCoverUrl || asset.providerUrl || '';
|
||||||
|
const mediaRef: MediaReference = {
|
||||||
|
name: asset.name || '尾帧图片',
|
||||||
|
type: 'image',
|
||||||
|
url: previewUrl,
|
||||||
|
role: 'last_frame',
|
||||||
|
label: '',
|
||||||
|
};
|
||||||
|
setLastFrame(mediaRef);
|
||||||
|
antdMessage.success('成功添加尾帧');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
uploading={uploading}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64, borderRadius: 8, border: '1px dashed rgba(139, 92, 246, 0.24)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'all 0.25s ease', backgroundColor: 'rgba(139, 92, 246, 0.04)', flexDirection: 'column', gap: 4 }}
|
style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64, borderRadius: 8, border: '1px dashed rgba(139, 92, 246, 0.24)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'all 0.25s ease', backgroundColor: 'rgba(139, 92, 246, 0.04)', flexDirection: 'column', gap: 4 }}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#a78bfa'; e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.08)'; }}
|
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#a78bfa'; e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.08)'; }}
|
||||||
@@ -2625,7 +2739,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
<PlusOutlined style={{ fontSize: 18, color: '#8b5cf6' }} />
|
<PlusOutlined style={{ fontSize: 18, color: '#8b5cf6' }} />
|
||||||
<span style={{ fontSize: 12, color: '#8b5cf6', fontWeight: 500 }}>上传尾帧</span>
|
<span style={{ fontSize: 12, color: '#8b5cf6', fontWeight: 500 }}>上传尾帧</span>
|
||||||
</div>
|
</div>
|
||||||
</Upload>
|
</UploadSelector>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64, borderRadius: 8, border: '1px dashed rgba(226, 232, 240, 0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'not-allowed', backgroundColor: 'rgba(241, 245, 249, 0.5)', flexDirection: 'column', gap: 4 }}
|
style={{ position: 'absolute', left: 10, right: 10, bottom: 10, height: 64, borderRadius: 8, border: '1px dashed rgba(226, 232, 240, 0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'not-allowed', backgroundColor: 'rgba(241, 245, 249, 0.5)', flexDirection: 'column', gap: 4 }}
|
||||||
@@ -2679,23 +2793,23 @@ const AIChatPage: React.FC = () => {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.25s ease',
|
transition: 'all 0.25s ease',
|
||||||
background: '#ffffff',
|
background: '#f4f4f4',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: 5,
|
gap: 5,
|
||||||
transform: 'rotate(-7deg)',
|
transform: 'rotate(-7deg)',
|
||||||
boxShadow: '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)',
|
// boxShadow: '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.borderColor = '#D7DDE7';
|
e.currentTarget.style.borderColor = '#D7DDE7';
|
||||||
e.currentTarget.style.background = 'linear-gradient(180deg, #ffffff 0%, #F7F8FA 100%)';
|
e.currentTarget.style.background = '#f4f4f4';
|
||||||
e.currentTarget.style.transform = 'rotate(0deg) translateY(-2px)';
|
e.currentTarget.style.transform = 'rotate(0deg) translateY(-2px)';
|
||||||
e.currentTarget.style.boxShadow = '0 14px 28px rgba(47, 52, 64, 0.15), inset 0 1px 0 rgba(255,255,255,0.98)';
|
// e.currentTarget.style.boxShadow = '0 14px 28px rgba(47, 52, 64, 0.15), inset 0 1px 0 rgba(255,255,255,0.98)';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.borderColor = 'rgba(231, 234, 240, 0.95)';
|
e.currentTarget.style.borderColor = 'rgba(231, 234, 240, 0.95)';
|
||||||
e.currentTarget.style.background = 'linear-gradient(180deg, #ffffff 0%, #FAFBFC 100%)';
|
e.currentTarget.style.background = '#f4f4f4';
|
||||||
e.currentTarget.style.transform = 'rotate(-7deg)';
|
e.currentTarget.style.transform = 'rotate(-7deg)';
|
||||||
e.currentTarget.style.boxShadow = '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)';
|
// e.currentTarget.style.boxShadow = '0 9px 20px rgba(47, 52, 64, 0.10), inset 0 1px 0 rgba(255,255,255,0.95)';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{uploading ? (
|
{uploading ? (
|
||||||
@@ -3125,6 +3239,8 @@ const AIChatPage: React.FC = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMediaType(item.value);
|
setMediaType(item.value);
|
||||||
setShowMediaTypeModal(false);
|
setShowMediaTypeModal(false);
|
||||||
|
setCurrentMedia([]);
|
||||||
|
setInputValue('');
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -3247,14 +3363,12 @@ const AIChatPage: React.FC = () => {
|
|||||||
key={engine.id}
|
key={engine.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCountType(engine.id);
|
setCountType(engine.id);
|
||||||
// 根据选中的引擎更新视频参数选项
|
|
||||||
if (mediaType === 'video') {
|
if (mediaType === 'video') {
|
||||||
setEngineOptions({
|
setEngineOptions({
|
||||||
ratios: engine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
ratios: engine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||||||
resolutions: engine.supportedResolutions || ['480p', '720p', '1080p'],
|
resolutions: engine.supportedResolutions || ['480p', '720p', '1080p'],
|
||||||
durations: engine.supportedDurations || [5, 8, 10, 12, 15],
|
durations: engine.supportedDurations || [5, 8, 10, 12, 15],
|
||||||
});
|
});
|
||||||
// 更新默认选中值,确保在新引擎支持的范围内
|
|
||||||
if (!engine.supportedRatios?.includes(videoAspectRatio)) {
|
if (!engine.supportedRatios?.includes(videoAspectRatio)) {
|
||||||
setVideoAspectRatio(engine.supportedRatios?.[0] || '16:9');
|
setVideoAspectRatio(engine.supportedRatios?.[0] || '16:9');
|
||||||
}
|
}
|
||||||
@@ -3265,7 +3379,44 @@ const AIChatPage: React.FC = () => {
|
|||||||
setVideoDuration(engine.supportedDurations?.[0] || 5);
|
setVideoDuration(engine.supportedDurations?.[0] || 5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (mediaType === 'image') {
|
||||||
|
const supportedSizes = engine.supportedSizes || {};
|
||||||
|
const resolutionLevels = Object.keys(supportedSizes).sort((a, b) => {
|
||||||
|
const levelOrder = { '1K': 0, '2K': 1, '4K': 2 };
|
||||||
|
return (levelOrder[a] || 0) - (levelOrder[b] || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryResolution = resolutionLevels[0] || '2K';
|
||||||
|
const supportedResolutions = Object.keys(supportedSizes[primaryResolution] || {});
|
||||||
|
|
||||||
|
const newRatioOptions = supportedResolutions.map((res: any) => ({
|
||||||
|
value: res,
|
||||||
|
label: res,
|
||||||
|
}));
|
||||||
|
setRatioOptions(newRatioOptions);
|
||||||
|
setCurrentEngineSupportedSizes(supportedSizes);
|
||||||
|
setResolutionOptions(resolutionLevels.map((level) => {
|
||||||
|
const match = level.match(/(\d+)K/);
|
||||||
|
const num = match ? parseInt(match[1]) : 1;
|
||||||
|
const labels: Record<number, string> = { 1: '标清', 2: '高清', 4: '超清' };
|
||||||
|
return {
|
||||||
|
value: level,
|
||||||
|
label: `${labels[num] || '高清'} ${level}`,
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (supportedSizes[primaryResolution] && supportedSizes[primaryResolution][supportedResolutions[0]]) {
|
||||||
|
const defaultSize = supportedSizes[primaryResolution][supportedResolutions[0]];
|
||||||
|
const [w, h] = defaultSize.split(/[×x]/);
|
||||||
|
setWidth(Number(w));
|
||||||
|
setHeight(Number(h));
|
||||||
|
setSelectedRatio(supportedResolutions[0]);
|
||||||
|
setSelectedResolution(primaryResolution);
|
||||||
|
}
|
||||||
|
}
|
||||||
setShowEngineModal(false);
|
setShowEngineModal(false);
|
||||||
|
setCurrentMedia([]);
|
||||||
|
setInputValue('');
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -3824,7 +3975,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
<span style={{ width: 1, height: 14, background: '#E7EAF0' }} />
|
<span style={{ width: 1, height: 14, background: '#E7EAF0' }} />
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#2f3440' }}>{videoResolution?.toUpperCase?.() || videoResolution}</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: '#2f3440' }}>{videoResolution?.toUpperCase?.() || videoResolution}</span>
|
||||||
<span style={{ width: 1, height: 14, background: '#E7EAF0' }} />
|
<span style={{ width: 1, height: 14, background: '#E7EAF0' }} />
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#2f3440' }}>{videoDuration}s</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: '#2f3440' }}>{videoDuration}秒</span>
|
||||||
<CaretDownOutlined style={{ fontSize: 10, color: '#667085', marginLeft: 'auto' }} />
|
<CaretDownOutlined style={{ fontSize: 10, color: '#667085', marginLeft: 'auto' }} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -4213,13 +4364,12 @@ const AIChatPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
// 关闭前强制停止视频播放,避免关闭后仍有声音
|
// 关闭前停止视频播放
|
||||||
if (attachmentPreviewVideoRef.current) {
|
if (attachmentPreviewVideoRef.current) {
|
||||||
const v = attachmentPreviewVideoRef.current;
|
const v = attachmentPreviewVideoRef.current;
|
||||||
v.pause();
|
v.pause();
|
||||||
v.muted = true;
|
v.muted = true;
|
||||||
v.removeAttribute('src');
|
v.currentTime = 0;
|
||||||
v.load();
|
|
||||||
}
|
}
|
||||||
setAttachmentPreviewVisible(false);
|
setAttachmentPreviewVisible(false);
|
||||||
}}
|
}}
|
||||||
@@ -4276,6 +4426,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
ref={attachmentPreviewVideoRef}
|
ref={attachmentPreviewVideoRef}
|
||||||
src={buildPreviewUrl(attachmentPreviewUrl)}
|
src={buildPreviewUrl(attachmentPreviewUrl)}
|
||||||
controls
|
controls
|
||||||
|
playsInline
|
||||||
style={{ maxWidth: '100%', maxHeight: '400px' }}
|
style={{ maxWidth: '100%', maxHeight: '400px' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -4288,6 +4439,13 @@ const AIChatPage: React.FC = () => {
|
|||||||
onSelect={handlePrivatePortraitAssetsSelected}
|
onSelect={handlePrivatePortraitAssetsSelected}
|
||||||
selectedIds={currentMedia.map((m) => m.private_asset_id).filter(Boolean) as string[]}
|
selectedIds={currentMedia.map((m) => m.private_asset_id).filter(Boolean) as string[]}
|
||||||
maxCount={Math.max(1, maxImage + maxVideo)}
|
maxCount={Math.max(1, maxImage + maxVideo)}
|
||||||
|
maxImageCount={maxImageCount}
|
||||||
|
maxVideoCount={maxVideoCount}
|
||||||
|
usedImageCount={currentMedia.filter(m => m.type === 'image').length}
|
||||||
|
usedVideoCount={currentMedia.filter(m => m.type === 'video').length}
|
||||||
|
usedVideoDuration={currentMedia.filter(m => m.type === 'video').reduce((sum, m) => sum + (m.duration || 0), 0)}
|
||||||
|
maxVideoDuration={15}
|
||||||
|
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -281,14 +281,42 @@ const GeneratePage: React.FC = () => {
|
|||||||
const videoCount = references.filter(
|
const videoCount = references.filter(
|
||||||
(r) => r.type === "video",
|
(r) => r.type === "video",
|
||||||
).length;
|
).length;
|
||||||
if (isImage && imageCount >= 10) {
|
const videoDuration = references
|
||||||
message.error("最多上传10张图片");
|
.filter((r) => r.type === "video")
|
||||||
|
.reduce((sum, r) => sum + (r.duration || 0), 0);
|
||||||
|
|
||||||
|
const MAX_IMAGES = 5;
|
||||||
|
const MAX_VIDEOS = 2;
|
||||||
|
const MAX_VIDEO_DURATION = 15;
|
||||||
|
|
||||||
|
if (isImage && imageCount >= MAX_IMAGES) {
|
||||||
|
message.error(`最多上传${MAX_IMAGES}张图片`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (isVideo && videoCount >= 3) {
|
if (isVideo && videoCount >= MAX_VIDEOS) {
|
||||||
message.error("最多上传3个视频");
|
message.error(`最多上传${MAX_VIDEOS}个视频`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
let fileDuration = 0;
|
||||||
|
if (isVideo) {
|
||||||
|
fileDuration = await new Promise<number>((resolve) => {
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.preload = "metadata";
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
resolve(video.duration || 0);
|
||||||
|
video.remove();
|
||||||
|
};
|
||||||
|
video.onerror = () => {
|
||||||
|
resolve(0);
|
||||||
|
video.remove();
|
||||||
|
};
|
||||||
|
video.src = URL.createObjectURL(file);
|
||||||
|
});
|
||||||
|
if (videoDuration + fileDuration > MAX_VIDEO_DURATION) {
|
||||||
|
message.error(`视频总时长不能超过${MAX_VIDEO_DURATION}秒`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
const uploadFn = isImage ? uploadImage : uploadVideo;
|
const uploadFn = isImage ? uploadImage : uploadVideo;
|
||||||
try {
|
try {
|
||||||
@@ -303,6 +331,7 @@ const GeneratePage: React.FC = () => {
|
|||||||
url: res.url,
|
url: res.url,
|
||||||
type: isImage ? "image" : "video",
|
type: isImage ? "image" : "video",
|
||||||
name: `${typeLabel}${typeCount}`,
|
name: `${typeLabel}${typeCount}`,
|
||||||
|
duration: isVideo ? fileDuration : undefined,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
message.success(`${typeLabel}上传成功`);
|
message.success(`${typeLabel}上传成功`);
|
||||||
@@ -1829,11 +1858,11 @@ const GeneratePage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onHistorySelect={(items) => {
|
onHistorySelect={(items) => {
|
||||||
items.forEach((item: any) => {
|
items.forEach((item) => {
|
||||||
setReferences(prev => [...prev, {
|
setReferences(prev => [...prev, {
|
||||||
url: '',
|
url: item.resourceUrl || item.previewUrl || item.displayUrl || '',
|
||||||
type: item.type,
|
type: item.resourceType,
|
||||||
name: item.name,
|
name: item.fileName || '',
|
||||||
}]);
|
}]);
|
||||||
});
|
});
|
||||||
message.success(`成功添加${items.length}个历史记录`);
|
message.success(`成功添加${items.length}个历史记录`);
|
||||||
@@ -1842,17 +1871,24 @@ const GeneratePage: React.FC = () => {
|
|||||||
items.forEach((item: any) => {
|
items.forEach((item: any) => {
|
||||||
setReferences(prev => [...prev, {
|
setReferences(prev => [...prev, {
|
||||||
url: item.previewUrl || '',
|
url: item.previewUrl || '',
|
||||||
type: 'image',
|
type: item.assetType === 'Video' ? 'video' : 'image',
|
||||||
name: item.name || '真人素材',
|
name: item.name || '真人素材',
|
||||||
source: 'private_portrait_asset',
|
source: 'private_portrait_asset',
|
||||||
private_asset_id: item.id,
|
private_asset_id: item.id,
|
||||||
label: '',
|
label: '',
|
||||||
|
duration: item.assetType === 'Video' ? (item.videoDuration || 0) : undefined,
|
||||||
}]);
|
}]);
|
||||||
});
|
});
|
||||||
message.success(`已添加 ${items.length} 个真人素材参考`);
|
message.success(`已添加 ${items.length} 个真人素材参考`);
|
||||||
}}
|
}}
|
||||||
uploading={uploading}
|
uploading={uploading}
|
||||||
tooltipTitle={`参考内容(${references.length}/10)`}
|
tooltipTitle={`图片${references.filter((r) => r.type === 'image').length}/5,视频${references.filter((r) => r.type === 'video').length}/2`}
|
||||||
|
maxImageCount={5}
|
||||||
|
maxVideoCount={2}
|
||||||
|
usedImageCount={references.filter((r) => r.type === 'image').length}
|
||||||
|
usedVideoCount={references.filter((r) => r.type === 'video').length}
|
||||||
|
usedVideoDuration={references.filter((r) => r.type === 'video').reduce((sum, r) => sum + (r.duration || 0), 0)}
|
||||||
|
maxVideoDuration={15}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -917,7 +917,7 @@ const GeneratedRecord: React.FC = () => {
|
|||||||
}));
|
}));
|
||||||
}, [filterType, filterMedia]);
|
}, [filterType, filterMedia]);
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: 'calc(100vh - 90px)', background: '#ffffffff', overflowY: 'auto' }} >
|
<div className="content_box" >
|
||||||
{/* 操作栏:筛选 + 推送按钮 */}
|
{/* 操作栏:筛选 + 推送按钮 */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
PictureOutlined,
|
PictureOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
|
HeartOutlined,
|
||||||
|
ShareAltOutlined,
|
||||||
|
StarOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getmedit ,getHomeCaseHeader,getHomeCaseButton} from '../api';
|
import { getmedit ,getHomeCaseHeader,getHomeCaseButton} from '../api';
|
||||||
@@ -43,7 +46,7 @@ const HomePage: React.FC = () => {
|
|||||||
const [caseAssets, setCaseAssets] = useState<any[]>([]);
|
const [caseAssets, setCaseAssets] = useState<any[]>([]);
|
||||||
const [previewAsset, setPreviewAsset] = useState<any>(null);
|
const [previewAsset, setPreviewAsset] = useState<any>(null);
|
||||||
const previewVideoRef = useRef<HTMLVideoElement>(null);
|
const previewVideoRef = useRef<HTMLVideoElement>(null);
|
||||||
const [activeContentTab, setActiveContentTab] = useState<'works' | 'cases'>('works');
|
const [activeContentTab, setActiveContentTab] = useState<'works' | 'cases'>('cases');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getHomeCaseHeader().then((res: any) => {
|
getHomeCaseHeader().then((res: any) => {
|
||||||
@@ -67,7 +70,7 @@ const HomePage: React.FC = () => {
|
|||||||
const fetchAll = async () => {
|
const fetchAll = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
getmedit(5).then((res) => {
|
getmedit(10).then((res) => {
|
||||||
for (let item in res) {
|
for (let item in res) {
|
||||||
res[item].forEach(element => {
|
res[item].forEach(element => {
|
||||||
// 给每条 element 标记它所属的模块(item 是接口返回的 key)
|
// 给每条 element 标记它所属的模块(item 是接口返回的 key)
|
||||||
@@ -479,10 +482,9 @@ const HomePage: React.FC = () => {
|
|||||||
onClick={() => navigate(entry.path)}
|
onClick={() => navigate(entry.path)}
|
||||||
className="project-card"
|
className="project-card"
|
||||||
style={{
|
style={{
|
||||||
flex: '1',
|
flex: '1 1 280px',
|
||||||
minWidth: 260,
|
minWidth: 280,
|
||||||
height: 120,
|
padding: '16px 20px',
|
||||||
padding: '16px',
|
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
border: '1px solid #e2e8f0',
|
border: '1px solid #e2e8f0',
|
||||||
@@ -492,6 +494,7 @@ const HomePage: React.FC = () => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 14,
|
gap: 14,
|
||||||
|
minHeight: 96,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.borderColor = accent.color;
|
e.currentTarget.style.borderColor = accent.color;
|
||||||
@@ -529,18 +532,27 @@ const HomePage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<span style={{ fontSize: 20, display: 'flex', transition: 'all 0.3s ease' }}>{entry.icon}</span>
|
<span style={{ fontSize: 20, display: 'flex', transition: 'all 0.3s ease' }}>{entry.icon}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<span style={{ fontSize: 15, fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
<span style={{ fontSize: 15, fontWeight: 600, color: '#1e293b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1, minWidth: 0 }}>
|
||||||
{entry.title}
|
{entry.title}
|
||||||
</span>
|
</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: 10, color: accent.color,
|
fontSize: 10, color: accent.color,
|
||||||
padding: '2px 7px', borderRadius: 4,
|
padding: '2px 7px', borderRadius: 4,
|
||||||
background: accent.light, fontWeight: 600,
|
background: accent.light, fontWeight: 600,
|
||||||
|
flexShrink: 0,
|
||||||
}}>{accent.tag}</span>
|
}}>{accent.tag}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: '#64748b', }}>
|
<div style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#64748b',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
{entry.description}
|
{entry.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -583,12 +595,13 @@ const HomePage: React.FC = () => {
|
|||||||
{/* 外层Tab切换:近期作品 / 素材案例 */}
|
{/* 外层Tab切换:近期作品 / 素材案例 */}
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
{[
|
{[
|
||||||
{ key: 'works', label: '近期作品' },
|
|
||||||
{ key: 'cases', label: '素材案例' },
|
{ key: 'cases', label: '素材案例' },
|
||||||
|
|
||||||
|
{ key: 'works', label: '近期作品' },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
onClick={() => setActiveContentTab(item.key as 'works' | 'cases')}
|
onClick={() => setActiveContentTab(item.key as 'cases' | 'works')}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 16px',
|
padding: '6px 16px',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
@@ -781,10 +794,10 @@ const HomePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 素材案例网格 */}
|
{/* 素材案例网格 */}
|
||||||
<div className="stagger-children" style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 16 }}>
|
<div className="stagger-children" style={{ display: 'flex', flexWrap: 'wrap', gap: 16, justifyContent: 'space-between', overflowX: 'auto', paddingBottom: 8 }}>
|
||||||
{caseAssets.length === 0 ? (
|
{caseAssets.length === 0 ? (
|
||||||
<div style={{
|
<div style={{
|
||||||
gridColumn: '1 / -1',
|
flex: 1,
|
||||||
padding: '60px 0',
|
padding: '60px 0',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: '#94a3b8',
|
color: '#94a3b8',
|
||||||
@@ -802,11 +815,12 @@ const HomePage: React.FC = () => {
|
|||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
background: '#fff',
|
// background: '#0f172a',
|
||||||
border: '1px solid #e2e8f0',
|
flexShrink: 0,
|
||||||
|
width: "18%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ position: 'relative', aspectRatio: '16/9', }}>
|
<div style={{ position: 'relative', aspectRatio: '9/16', }}>
|
||||||
{asset.mediaType === 'video' ? (
|
{asset.mediaType === 'video' ? (
|
||||||
<>
|
<>
|
||||||
<video
|
<video
|
||||||
@@ -821,9 +835,18 @@ const HomePage: React.FC = () => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
background: 'rgba(0,0,0,0.2)',
|
|
||||||
}}>
|
}}>
|
||||||
<VideoCameraOutlined style={{ fontSize: 28, color: '#fff' }} />
|
<div style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<PlayCircleOutlined style={{ fontSize: 24, color: '#fff' }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -833,22 +856,43 @@ const HomePage: React.FC = () => {
|
|||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
{/* {asset.mediaType === 'video' && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '10px 12px',
|
position: 'absolute',
|
||||||
background: '#f8fafc',
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#fff',
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 4,
|
||||||
}}>
|
}}>
|
||||||
|
AI生成
|
||||||
|
</div>
|
||||||
|
)} */}
|
||||||
|
</div>
|
||||||
|
{/* <div style={{ padding: '8px 10px' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
color: '#64748b',
|
color: '#94a3b8',
|
||||||
textAlign: 'center',
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
|
marginBottom: 4,
|
||||||
}}>
|
}}>
|
||||||
{asset.title || `素材 ${index + 1}`}
|
{asset.title || `素材 ${index + 1}`}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#94a3b8',
|
||||||
|
}}>
|
||||||
|
<HeartOutlined style={{ fontSize: 12 }} />
|
||||||
|
<span>{asset.likes || Math.floor(Math.random() * 1000)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -864,60 +908,248 @@ const HomePage: React.FC = () => {
|
|||||||
setPreviewAsset(null);
|
setPreviewAsset(null);
|
||||||
}}
|
}}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={760}
|
width={900}
|
||||||
centered
|
centered
|
||||||
className="preview-modal"
|
className="preview-modal"
|
||||||
bodyStyle={{
|
style={{ borderRadius: 16, overflow: 'hidden' }}
|
||||||
padding: 0,
|
|
||||||
background: '#fff',
|
|
||||||
borderRadius: 16,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* 固定比例容器 16:9 */}
|
<div style={{ display: 'flex', height: 580 }}>
|
||||||
<div style={{
|
{/* 左侧:素材预览 */}
|
||||||
width: '100%',
|
<div style={{ flex: 1, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
||||||
paddingTop: '56.25%',
|
<div style={{ width: 280, aspectRatio: '9/16', borderRadius: 12, overflow: 'hidden', background: '#0f172a' }}>
|
||||||
position: 'relative',
|
|
||||||
// background: '#000',
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}>
|
|
||||||
{previewAsset?.mediaType === 'video' ? (
|
{previewAsset?.mediaType === 'video' ? (
|
||||||
<video
|
<video
|
||||||
ref={previewVideoRef}
|
ref={previewVideoRef}
|
||||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset.url}`}
|
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset.url}`}
|
||||||
controls
|
controls
|
||||||
autoPlay
|
autoPlay
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
webkit-playsinline="true"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset?.url}`}
|
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset?.url}`}
|
||||||
alt={previewAsset?.title}
|
alt={previewAsset?.title}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
{previewAsset?.mediaType === 'video' && (
|
||||||
</div>
|
|
||||||
{/* 底部标题栏 */}
|
|
||||||
{previewAsset?.title && (
|
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '14px 20px',
|
position: 'absolute',
|
||||||
fontSize: 14,
|
top: 32,
|
||||||
color: '#4b5563',
|
right: 32,
|
||||||
fontWeight: 500,
|
fontSize: 11,
|
||||||
borderTop: '1px solid #f1f5f9',
|
color: '#fff',
|
||||||
textAlign: 'center',
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
padding: '3px 8px',
|
||||||
|
borderRadius: 4,
|
||||||
}}>
|
}}>
|
||||||
{previewAsset.title}
|
AI生成
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:创意详情 */}
|
||||||
|
<div style={{ flex: 1, padding: 24, overflowY: 'auto' }}>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 600, color: '#fff', marginBottom: 16 }}>
|
||||||
|
创意详情
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 热度 */}
|
||||||
|
{/* <div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#94a3b8' }}>
|
||||||
|
<span style={{ color: '#f59e0b' }}>热度:</span>
|
||||||
|
{previewAsset?.likes || Math.floor(Math.random() * 5000)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#94a3b8' }}>
|
||||||
|
<span style={{ color: '#6366f1' }}>热度:</span>
|
||||||
|
{Math.floor(Math.random() * 1000)}
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
{/* 视频提示词 */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#000000ff', marginBottom: 8 }}>视频提示词</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#000000ff', lineHeight: 1.6 }}>
|
||||||
|
{previewAsset?.prompt || '动态描述:女性抬手整理头发,随后手持口服液用手指向产品讲解;画面切换为双手将口服液中的棕黄色液体缓缓倒入透明玻璃杯;再次切换女性讲解画面,双手做出展示动作指向产品;最后双手在胸前做出托手姿势后摊开手掌微笑描述:女性讲解的...'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 视频参考图 */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#94a3b8', marginBottom: 8 }}>视频参考图</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<div style={{ width: 80, height: 80, borderRadius: 8, overflow: 'hidden', cursor: 'pointer' }}>
|
||||||
|
<img
|
||||||
|
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewAsset?.url}`}
|
||||||
|
alt="参考图"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px dashed #475569',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#64748b',
|
||||||
|
fontSize: 12,
|
||||||
|
}}>
|
||||||
|
展开查看
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 口播脚本台词 */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#000000ff', marginBottom: 8 }}>口播脚本台词</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#000000ff', lineHeight: 1.6 }}>
|
||||||
|
{previewAsset?.script || '还在为宝宝不爱喝水发愁?试试这款天然果蔬汁!零添加糖分,维生素满满,口感清甜宝宝超爱喝。现在下单还送专属吸管杯,手慢无!点下方链接把健康带回家~'}
|
||||||
|
</div>
|
||||||
|
{/* <div style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#6366f1',
|
||||||
|
marginTop: 8,
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
}}>
|
||||||
|
完整内容女声
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 视频标签 */}
|
||||||
|
{/* <div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#94a3b8', marginBottom: 8 }}>视频标签</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{previewAsset?.tags?.split?.(',')?.map((tag: string, i: number) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: 16,
|
||||||
|
background: '#334155',
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag.trim()}
|
||||||
|
</span>
|
||||||
|
)) || ['互联网电商服务', '美妆', '美妆', '服装配饰', '母婴宠物', '带货主播', '口播'].map((tag, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: 16,
|
||||||
|
background: '#334155',
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部操作栏 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 24px',
|
||||||
|
borderTop: '1px solid #334155',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}>
|
||||||
|
{/* <div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => message.info('收藏功能开发中')}
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: 'none',
|
||||||
|
background: '#334155',
|
||||||
|
color: '#94a3b8',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = '#475569';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = '#334155';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StarOutlined style={{ fontSize: 16 }} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => message.info('分享功能开发中')}
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: 'none',
|
||||||
|
background: '#334155',
|
||||||
|
color: '#000000ff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = '#475569';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = '#334155';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShareAltOutlined style={{ fontSize: 16 }} />
|
||||||
|
</button>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
{previewAsset?.mediaType !== 'video' && (
|
||||||
|
<Button
|
||||||
|
// onClick={() => navigate('/generate')}
|
||||||
|
style={{
|
||||||
|
padding: '8px 24px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#3b82f6',
|
||||||
|
border: 'none',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
去AI创作
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
// onClick={() => navigate('/initial')}
|
||||||
|
style={{
|
||||||
|
padding: '8px 24px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#8b5cf6',
|
||||||
|
border: 'none',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
去爆款复刻
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Table,
|
Table,
|
||||||
Space,
|
Space,
|
||||||
Pagination,
|
Pagination,
|
||||||
|
Popconfirm,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
@@ -18,7 +19,8 @@ import {
|
|||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { uploadHotOpeningVideo, uploadHotOpeningImage, generateReplication, getReplicationList, getone, getReplicationDetail } from '../api';
|
import { uploadHotOpeningVideo, uploadHotOpeningImage, generateReplication, getReplicationList, getone, getReplicationDetail, deleteHotOpeningReplicationTask } from '../api';
|
||||||
|
|
||||||
|
|
||||||
const { Header, Content } = Layout;
|
const { Header, Content } = Layout;
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@@ -1227,7 +1229,7 @@ const GenerateConver: React.FC = () => {
|
|||||||
dataIndex: 'targetProjectName',
|
dataIndex: 'targetProjectName',
|
||||||
key: 'targetProjectName',
|
key: 'targetProjectName',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 200,
|
width: 100,
|
||||||
render: (text: string) => (
|
render: (text: string) => (
|
||||||
<span style={{ fontSize: 13, color: '#1e293b', fontWeight: 500 }}>
|
<span style={{ fontSize: 13, color: '#1e293b', fontWeight: 500 }}>
|
||||||
{text || '-'}
|
{text || '-'}
|
||||||
@@ -1350,8 +1352,9 @@ const GenerateConver: React.FC = () => {
|
|||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 120,
|
width: 220,
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/initial/${record.id}/initialinfo`)}
|
onClick={() => navigate(`/initial/${record.id}/initialinfo`)}
|
||||||
style={{
|
style={{
|
||||||
@@ -1368,6 +1371,42 @@ const GenerateConver: React.FC = () => {
|
|||||||
>
|
>
|
||||||
查看详情
|
查看详情
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除这个爆款开头复刻任务吗?"
|
||||||
|
onConfirm={async () => {
|
||||||
|
try {
|
||||||
|
await deleteHotOpeningReplicationTask(record.id);
|
||||||
|
message.success('删除成功');
|
||||||
|
fetchList(1, pageSize, false, searchKeyword);
|
||||||
|
} catch (err) {
|
||||||
|
message.error('删除失败');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
color: '#ef4444',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontSize: 13,
|
||||||
|
border: 'none',
|
||||||
|
background: 'rgba(239, 68, 68, 0.08)',
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: 8,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = 'rgba(239, 68, 68, 0.12)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = 'rgba(239, 68, 68, 0.08)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { Button, Drawer, Input, Modal, Popconfirm, Spin, Table, Tag, Tooltip, Upload, message } from 'antd';
|
import { Button, Drawer, Input, Modal, Popconfirm, Spin, Table, Tag, Tooltip, Upload, message } from 'antd';
|
||||||
import { ArrowLeftOutlined, PlusOutlined, XOutlined } from '@ant-design/icons';
|
import { ArrowLeftOutlined, PlusOutlined, XOutlined } from '@ant-design/icons';
|
||||||
import { createRemoveLens, getShotReplicationDetail, Removelist, removeCreate, splitCustom, uploadShotReplicateImage } from '../api';
|
|
||||||
|
import {uploadShotReplicateImage, createRemoveLens, deleteSegment, getShotReplicationDetail, reanalyzeSegment, Removelist, removeCreate, reanalyzeShotReplication, splitCustom, uploadImage } from '../api';
|
||||||
|
|
||||||
import VideoTrimPicker from '../components/VideoTrimPicker';
|
import VideoTrimPicker from '../components/VideoTrimPicker';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@@ -132,6 +134,51 @@ function RemoveInfo() {
|
|||||||
}
|
}
|
||||||
}, [creatID]);
|
}, [creatID]);
|
||||||
|
|
||||||
|
const handleReanalyze = useCallback(async () => {
|
||||||
|
if (!creatID) return;
|
||||||
|
try {
|
||||||
|
await reanalyzeShotReplication(creatID);
|
||||||
|
message.success('重新分析已提交');
|
||||||
|
fetchTaskDetail();
|
||||||
|
if (!analysisPollingRef.current) {
|
||||||
|
analysisPollingRef.current = window.setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getShotReplicationDetail(creatID);
|
||||||
|
setTaskDetail(res);
|
||||||
|
if (res.analysisStatus === 'completed' || res.analysisStatus === 'failed') {
|
||||||
|
if (analysisPollingRef.current) {
|
||||||
|
clearInterval(analysisPollingRef.current);
|
||||||
|
analysisPollingRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.message || '重新分析失败');
|
||||||
|
}
|
||||||
|
}, [creatID, fetchTaskDetail]);
|
||||||
|
|
||||||
|
const handleSegmentReanalyze = useCallback(async (segmentId: string) => {
|
||||||
|
try {
|
||||||
|
await reanalyzeSegment(segmentId);
|
||||||
|
message.success('重新分析已提交');
|
||||||
|
fetchSegments();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.message || '重新分析失败');
|
||||||
|
}
|
||||||
|
}, [fetchSegments]);
|
||||||
|
|
||||||
|
const handleDeleteSegment = useCallback(async (segmentId: string) => {
|
||||||
|
try {
|
||||||
|
await deleteSegment(segmentId);
|
||||||
|
message.success('删除成功');
|
||||||
|
fetchSegments();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.message || '删除失败');
|
||||||
|
}
|
||||||
|
}, [fetchSegments]);
|
||||||
|
|
||||||
const refreshPageData = useCallback(async () => {
|
const refreshPageData = useCallback(async () => {
|
||||||
await Promise.all([fetchTaskDetail(), fetchSegments()]);
|
await Promise.all([fetchTaskDetail(), fetchSegments()]);
|
||||||
}, [fetchTaskDetail, fetchSegments]);
|
}, [fetchTaskDetail, fetchSegments]);
|
||||||
@@ -233,6 +280,7 @@ function RemoveInfo() {
|
|||||||
|
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
|
|
||||||
target_project_name: productName.trim(),
|
target_project_name: productName.trim(),
|
||||||
core_content_point: productSellingPoint.trim(),
|
core_content_point: productSellingPoint.trim(),
|
||||||
material_image_url: productImage,
|
material_image_url: productImage,
|
||||||
@@ -564,6 +612,28 @@ function RemoveInfo() {
|
|||||||
{canCreateReplication(record) ? '视频生成' : '待切割完成'}
|
{canCreateReplication(record) ? '视频生成' : '待切割完成'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{record.analysisStatus === 'failed' && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
onClick={() => handleSegmentReanalyze(String(record.id))}
|
||||||
|
style={{ color: '#ef4444', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
|
||||||
|
>
|
||||||
|
重新分析
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除此片段?"
|
||||||
|
onConfirm={() => handleDeleteSegment(String(record.id))}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
style={{ color: '#ef4444', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -618,7 +688,7 @@ function RemoveInfo() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{taskDetail ? (
|
{taskDetail ? (
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', overflow: 'hidden', padding: '0 32px 32px' }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', overflow: 'hidden', }}>
|
||||||
{/* 视频总结卡片 */}
|
{/* 视频总结卡片 */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -631,21 +701,74 @@ function RemoveInfo() {
|
|||||||
gap: 30,
|
gap: 30,
|
||||||
padding: 24,
|
padding: 24,
|
||||||
border: '1px solid rgba(99, 102, 241, 0.1)',
|
border: '1px solid rgba(99, 102, 241, 0.1)',
|
||||||
boxShadow: '0 8px 32px rgba(99, 102, 241, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.8)',
|
// boxShadow: '0 8px 32px rgba(99, 102, 241, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.8)',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 卡片装饰 */}
|
|
||||||
<div style={{ position: 'absolute', top: -50, right: -50, width: 200, height: 200, background: 'radial-gradient(circle, rgba(99,102,241,0.05) 0%, transparent 70%)', borderRadius: '50%' }} />
|
|
||||||
|
|
||||||
<div style={{ position: 'relative', width: 280, height: 160, borderRadius: 12, overflow: 'hidden', flexShrink: 0, boxShadow: '0 4px 16px rgba(0,0,0,0.1)' }}>
|
<div>
|
||||||
|
<div
|
||||||
|
style={{ position: 'relative', width: 280, height: 160, borderRadius: 12, overflow: 'hidden', flexShrink: 0, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', cursor: 'pointer' }}
|
||||||
|
onClick={() => {
|
||||||
|
setPreviewVideoUrl(videoUrl);
|
||||||
|
setPreviewModalVisible(true);
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
const overlay = e.currentTarget.querySelector('div:last-child') as HTMLElement;
|
||||||
|
if (overlay) overlay.style.opacity = '1';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
const overlay = e.currentTarget.querySelector('div:last-child') as HTMLElement;
|
||||||
|
if (overlay) overlay.style.opacity = '0';
|
||||||
|
}}
|
||||||
|
>
|
||||||
<video
|
<video
|
||||||
controls
|
|
||||||
src={videoUrl}
|
src={videoUrl}
|
||||||
style={{ width: '100%', height: '100%'}}
|
style={{ width: '100%', height: '100%', objectFit: 'cover', }}
|
||||||
/>
|
/>
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0, transition: 'opacity 0.2s' }}>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: '50%', background: 'rgba(255,255,255,0.9)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#6366f1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="5 3 19 12 5 21 5 3"></polygon>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{taskDetail.analysisStatus === 'failed' && (
|
||||||
|
<div style={{ marginTop: 12, textAlign: 'center' }}>
|
||||||
|
<Button
|
||||||
|
onClick={handleReanalyze}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: '6px 16px',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
border: '1px solid #ef4444',
|
||||||
|
color: '#ef4444',
|
||||||
|
background: 'rgba(239, 68, 68, 0.05)',
|
||||||
|
cursor: taskDetail.analysisStatus === 'processing' ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (taskDetail.analysisStatus !== 'processing') {
|
||||||
|
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.05)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{taskDetail.analysisStatus === 'processing' ? '分析中...' : '重新分析'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', position: 'relative', zIndex: 1 }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', position: 'relative', zIndex: 1 }}>
|
||||||
<div>
|
<div>
|
||||||
@@ -674,6 +797,8 @@ function RemoveInfo() {
|
|||||||
</Tag>
|
</Tag>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : taskDetail.analysisStatus === 'failed' ? (
|
||||||
|
<span style={{ color: '#ef4444', fontSize: 14 }}>分析失败</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex' }}>
|
<div style={{ display: 'flex' }}>
|
||||||
@@ -685,6 +810,8 @@ function RemoveInfo() {
|
|||||||
</div>
|
</div>
|
||||||
) : taskDetail.analysisStatus === 'completed' ? (
|
) : taskDetail.analysisStatus === 'completed' ? (
|
||||||
<span style={{ color: '#475569', lineHeight: 1.6, height: 100, overflowY: 'auto' }}>{taskDetail.originalVideoContent}</span>
|
<span style={{ color: '#475569', lineHeight: 1.6, height: 100, overflowY: 'auto' }}>{taskDetail.originalVideoContent}</span>
|
||||||
|
) : taskDetail.analysisStatus === 'failed' ? (
|
||||||
|
<span style={{ color: '#ef4444', fontSize: 14 }}>分析失败</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1026,7 +1153,7 @@ function RemoveInfo() {
|
|||||||
src={previewVideoUrl || ''}
|
src={previewVideoUrl || ''}
|
||||||
style={{ width: '100%', maxHeight: 420, objectFit: 'contain', backgroundColor: '#000' }}
|
style={{ width: '100%', maxHeight: 420, objectFit: 'contain', backgroundColor: '#000' }}
|
||||||
playsInline
|
playsInline
|
||||||
webkit-playsinline
|
webkit-playsinline="true"
|
||||||
autoPlay
|
autoPlay
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
import { useState, useRef, useCallback } from 'react';
|
import { useState, useRef, useCallback } from 'react';
|
||||||
import { Button, Modal, Input, Table, Upload, Popconfirm, message } from 'antd';
|
import { Button, Modal, Input, Table, Upload, Popconfirm, Tag, Space, message } from 'antd';
|
||||||
import { FileTextOutlined, CloudUploadOutlined } from '@ant-design/icons';
|
import { FileTextOutlined, CloudUploadOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { uploadShotReplicateVideo, createShotReplication, getShotReplicationList } from '../api';
|
|
||||||
|
import { uploadShotReplicateVideo, createShotReplication, getShotReplicationList, deleteShotReplicationProject } from '../api';
|
||||||
|
|
||||||
import bg1 from '../assets/bg1.png';
|
import bg1 from '../assets/bg1.png';
|
||||||
|
|
||||||
|
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||||
|
pending_analysis: { label: '等待分析', color: 'default' },
|
||||||
|
analyzing: { label: '分析中', color: 'processing' },
|
||||||
|
analysis_completed: { label: '分析完成', color: 'blue' },
|
||||||
|
analysis_failed: { label: '分析失败', color: 'red' },
|
||||||
|
splitting: { label: '拆镜中', color: 'processing' },
|
||||||
|
split_completed: { label: '拆镜完成', color: 'green' },
|
||||||
|
partial_failed: { label: '部分失败', color: 'orange' },
|
||||||
|
failed: { label: '失败', color: 'red' },
|
||||||
|
deleted: { label: '已软删', color: 'default' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStatus = (status: string) => {
|
||||||
|
const config = statusConfig[status] || { label: status, color: 'default' };
|
||||||
|
return <Tag color={config.color}>{config.label}</Tag>;
|
||||||
|
};
|
||||||
|
|
||||||
export default function VideoFrameExtractor() {
|
export default function VideoFrameExtractor() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -546,6 +565,12 @@ export default function VideoFrameExtractor() {
|
|||||||
<span style={{ fontSize: 14, color: '#1e293b', fontWeight: 500 }}>{text}</span>
|
<span style={{ fontSize: 14, color: '#1e293b', fontWeight: 500 }}>{text}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
render: (status: string) => renderStatus(status),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '创建时间',
|
title: '创建时间',
|
||||||
dataIndex: 'createdAt',
|
dataIndex: 'createdAt',
|
||||||
@@ -567,6 +592,7 @@ export default function VideoFrameExtractor() {
|
|||||||
key: 'action',
|
key: 'action',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (record) => (
|
render: (record) => (
|
||||||
|
<Space>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
onClick={() => navigate(`/removelens/${record.id}/removeinfo`)}
|
onClick={() => navigate(`/removelens/${record.id}/removeinfo`)}
|
||||||
@@ -586,6 +612,39 @@ export default function VideoFrameExtractor() {
|
|||||||
>
|
>
|
||||||
查看详情
|
查看详情
|
||||||
</Button>
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除这个拆镜项目吗?"
|
||||||
|
onConfirm={async () => {
|
||||||
|
try {
|
||||||
|
await deleteShotReplicationProject(record.id);
|
||||||
|
message.success('删除成功');
|
||||||
|
fetchList(currentPage, pageSize, searchKeyword);
|
||||||
|
} catch (err) {
|
||||||
|
message.error('删除失败');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
style={{
|
||||||
|
color: '#ef4444',
|
||||||
|
fontSize: 13,
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.15)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
Reference in New Issue
Block a user