Refine seller store implementation

This commit is contained in:
R524809
2026-08-31 14:37:28 +08:00
parent 957289f717
commit 2a667b4f3b
27 changed files with 1650 additions and 624 deletions
+69 -564
View File
@@ -5,10 +5,10 @@
*/
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import {
Alert, App as AntdApp, Button, Card, Checkbox, Col, Empty, Image, Input, InputNumber, Popconfirm, Popover, Progress,
Radio, Row, Segmented, Select, Space, Tag, Typography, Upload, type ImageProps,
Alert, App as AntdApp, Button, Card, Checkbox, Col, Empty, Input, Popconfirm, Popover, Progress,
Row, Segmented, Select, Space, Tag, Typography, Upload,
} from 'antd';
import { DeleteOutlined, DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons';
import { DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons';
import { ProductAsset, ProductDetail } from '@/services/product';
import { apiErrorMessage } from '@/services/api';
import {
@@ -19,21 +19,14 @@ import {
} from '@/services/suite';
import { cleanFilename, downloadBlob } from '@/utils/file';
import AiImageGenModal from './AiImageGenModal';
import { CollectionImageGroups, FailedSuiteImages, GeneratedImageGrid } from './TrialImageGrids';
import TrialWatermarkSettings from './TrialWatermarkSettings';
import TrialVideoMaker from './TrialVideoMaker';
import { DISPLAY_GROUPS, GROUP_LABELS, type ImagePreviewConfig, type ResultImage } from './trialImageTypes';
const { Text } = Typography;
const GROUP_LABELS: Record<string, string> = {
main: '主图',
sku: 'SKU 图',
detail: '详情图',
generated: '生成图',
upload: '手动上传',
param: '参数图',
video: '视频',
};
const DISPLAY_GROUPS = ['main', 'sku', 'detail', 'generated', 'upload'];
const WATERMARK_STORAGE_KEY = 'trialWatermark';
type ImagePreviewConfig = Pick<NonNullable<Exclude<ImageProps['preview'], boolean>>, 'actionsRender'>;
/** 方案数量步进器:左减号 / 中间可输入 / 右加号(0-5) */
function PlanStepper({ value, onChange }: { value: number; onChange: (v: number) => void }) {
@@ -177,7 +170,6 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
const [selectedResultKeys, setSelectedResultKeys] = useState<Set<string>>(() => new Set());
/** 删除后在本次任务的内存结果中同步隐藏,避免刷新前仍可导出已删图片。 */
const [hiddenResultKeys, setHiddenResultKeys] = useState<Set<string>>(() => new Set());
const [hoveredImageKey, setHoveredImageKey] = useState<string | null>(null);
const [downloadingResultKeys, setDownloadingResultKeys] = useState<Set<string>>(() => new Set());
const [applyingWatermark, setApplyingWatermark] = useState(false);
const [watermarkJob, setWatermarkJob] = useState<SuiteInfo | null>(null);
@@ -212,7 +204,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
};
const rawObj = (product.raw ?? {}) as Record<string, unknown>;
const productTitle = ((rawObj.title_zh as string) || (rawObj.title as string) || product.name || '').trim();
const resultImages = useMemo(() => {
const resultImages = useMemo<ResultImage[]>(() => {
const suiteImages = (suite?.images ?? [])
.filter((img) => img.status === 'done')
.map((img, index) => {
@@ -899,155 +891,27 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
};
const watermarkPopup = (
<div style={{ width: 280 }}>
<Checkbox
checked={watermark.enabled}
onChange={(e) => patchWatermark({ enabled: e.target.checked })}
>
新生成图片自动加水印
</Checkbox>
{watermarkJob && (
<Alert
showIcon
type={watermarkJob.status === 'done' ? 'success' : watermarkJob.status === 'failed' ? 'error' : 'info'}
style={{ marginTop: 10 }}
message={
['queued', 'running'].includes(watermarkJob.status)
? `水印处理中:${watermarkJob.completed ?? 0}/${watermarkJob.total ?? 0}`
: watermarkJob.status === 'partial'
? `水印部分完成:${watermarkJob.completed ?? 0} 成功,${watermarkJob.failed ?? 0} 失败`
: watermarkJob.status === 'done'
? `水印已完成:${watermarkJob.completed ?? watermarkJob.total ?? 0} 张`
: watermarkJob.status === 'cancelled'
? '水印任务已取消'
: '水印任务失败'
}
description={
<Space direction="vertical" size={4} style={{ width: '100%' }}>
{watermarkJob.error && <Text type="secondary" style={{ fontSize: 11 }}>{watermarkJob.error}</Text>}
{['partial', 'failed'].includes(watermarkJob.status) && (
<Button size="small" onClick={() => void handleRetryWatermarkJob()}>重试所有失败项</Button>
)}
{['queued', 'running'].includes(watermarkJob.status) && (
<Popconfirm
title="取消水印任务"
description="尚未处理的图片将停止;已经完成的图片会保留水印版。"
okText="取消任务"
cancelText="继续处理"
okButtonProps={{ danger: true }}
onConfirm={() => void handleCancelWatermarkJob()}
>
<Button size="small" danger>取消水印任务</Button>
</Popconfirm>
)}
{watermarkJob.images.filter((item) => item.status === 'failed').map((item) => (
<Space key={item.item_id} size={4} align="start">
<Text type="danger" style={{ fontSize: 11, maxWidth: 185 }} ellipsis={{ tooltip: item.error || item.name }}>
{item.name}:{item.error || '处理失败'}
</Text>
<Button size="small" onClick={() => void handleRetryWatermarkItem(item.item_id)}>重试</Button>
</Space>
))}
</Space>
}
/>
<TrialWatermarkSettings
watermark={watermark}
watermarkJob={watermarkJob}
applying={applyingWatermark}
selectedCollectionCount={selectedCollectionWatermarkAssetIds.length}
selectedResultCount={selectedWatermarkAssetIds.length}
selectedWatermarkedCollectionCount={selectedWatermarkedCollectionIds.length}
selectedWatermarkedResultCount={selectedWatermarkedResultIds.length}
onPatch={patchWatermark}
onApplyCollection={() => handleApplyWatermark(selectedCollectionWatermarkAssetIds, '采集图片')}
onApplyResult={() => handleApplyWatermark(selectedWatermarkAssetIds, '生成结果图片', selectedWatermarkItems)}
onRemoveCollection={() => handleRemoveWatermark(selectedWatermarkedCollectionIds, '采集图片')}
onRemoveResult={() => handleRemoveWatermark(
selectedWatermarkedResultIds,
'生成结果图片',
selectedWatermarkItems.filter((item) => assetById.get(item.assetId!)?.watermark_enabled),
)}
{watermark.enabled && (
<>
<div style={{ margin: '10px 0 6px' }}>
<Radio.Group
size="small"
optionType="button"
buttonStyle="solid"
value={watermark.type}
onChange={(e) => patchWatermark({ type: e.target.value })}
options={[
{ value: 'image', label: '图片徽章' },
{ value: 'text', label: '文字' },
]}
/>
</div>
{watermark.type === 'text' && (
<Input
size="small"
value={watermark.text}
placeholder="水印文字"
onChange={(e) => patchWatermark({ text: e.target.value })}
/>
)}
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
不透明度(%)
</Text>
<InputNumber
size="small"
style={{ width: '100%' }}
min={1}
max={100}
step={5}
value={watermark.opacity}
onChange={(v) => patchWatermark({ opacity: v ?? 30 })}
/>
</div>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 6 }}>
位置固定右下角;一键与单张 AI 生图会直接按此设置合成水印,也可应用到已选采集图或生成图
</Text>
<Button
type="primary"
size="small"
block
style={{ marginTop: 10 }}
loading={applyingWatermark}
disabled={applyingWatermark || selectedCollectionWatermarkAssetIds.length === 0}
onClick={() => handleApplyWatermark(selectedCollectionWatermarkAssetIds, '采集图片')}
>
为已选采集图添加水印({selectedCollectionWatermarkAssetIds.length})
</Button>
<Button
type="primary"
size="small"
block
style={{ marginTop: 6 }}
loading={applyingWatermark}
disabled={applyingWatermark || selectedWatermarkAssetIds.length === 0}
onClick={() => handleApplyWatermark(selectedWatermarkAssetIds, '生成结果图片', selectedWatermarkItems)}
>
为已选生成图添加水印({selectedWatermarkAssetIds.length})
</Button>
</>
)}
{(selectedWatermarkedCollectionIds.length > 0 || selectedWatermarkedResultIds.length > 0) && (
<>
<Text type="secondary" style={{ display: 'block', fontSize: 11, marginTop: 10 }}>
移除水印会恢复无水印原图,并删除当前水印版
</Text>
<Button
size="small"
block
style={{ marginTop: 6 }}
loading={applyingWatermark}
disabled={applyingWatermark || selectedWatermarkedCollectionIds.length === 0}
onClick={() => handleRemoveWatermark(selectedWatermarkedCollectionIds, '采集图片')}
>
移除已选采集图水印({selectedWatermarkedCollectionIds.length})
</Button>
<Button
size="small"
block
style={{ marginTop: 6 }}
loading={applyingWatermark}
disabled={applyingWatermark || selectedWatermarkedResultIds.length === 0}
onClick={() => handleRemoveWatermark(
selectedWatermarkedResultIds,
'生成结果图片',
selectedWatermarkItems.filter((item) => assetById.get(item.assetId!)?.watermark_enabled),
)}
>
移除已选生成图水印({selectedWatermarkedResultIds.length})
</Button>
</>
)}
</div>
onCancelJob={() => void handleCancelWatermarkJob()}
onRetryJob={() => void handleRetryWatermarkJob()}
onRetryItem={(itemId) => void handleRetryWatermarkItem(itemId)}
/>
);
const selectableCount = imgs.length;
@@ -1120,152 +984,17 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
{displayGroups.length === 0 ? (
<Empty description="暂无素材,可点击右上角「上传图片」补充参考底图" />
) : (
<Image.PreviewGroup preview={imagePreviewConfig}>
{displayGroups.map((g) => (
<div key={g} style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Text strong>
{GROUP_LABELS[g] ?? g}
<Text type="secondary" style={{ fontWeight: 400, marginLeft: 6 }}>
{groupImages(g).length}
</Text>
</Text>
<a
style={{ fontSize: 12 }}
onClick={() => {
const all = groupImages(g).every((a) => selectedKeys.has(a.id));
toggleGroup(g, !all);
}}
>
{groupImages(g).every((a) => selectedKeys.has(a.id)) ? '取消全选' : '全选'}
</a>
{g === 'main' && (
<Text type="secondary" style={{ fontSize: 11 }}>
勾选的图片作为生图参考底图
</Text>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: 10 }}>
{groupImages(g).map((a) => {
const on = selectedKeys.has(a.id);
return (
<div key={a.id}>
<div
onMouseEnter={() => setHoveredImageKey(`collect-${a.id}`)}
onMouseLeave={() => setHoveredImageKey(null)}
style={{
position: 'relative',
border: `2px solid ${on ? '#1677ff' : 'transparent'}`,
borderRadius: 8,
padding: 2,
background: on ? 'rgba(22,119,255,0.06)' : undefined,
}}
>
<Image
src={assetUrl(a)}
width="100%"
style={{ objectFit: 'cover', borderRadius: 6, aspectRatio: '1 / 1', display: 'block' }}
wrapperStyle={{ width: '100%', display: 'block' }}
fallback="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'><rect width='100' height='100' fill='%23eee'/><text x='22' y='52' font-size='11' fill='%23999'>无预览</text></svg>"
/>
<Popconfirm
title="删除图片"
description="确认删除这张采集图片?"
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => handleDeleteAsset(a.id)}
>
<Button
size="small"
danger
type="text"
icon={<DeleteOutlined />}
title="删除这张图片"
onClick={(e) => e.stopPropagation()}
style={{
position: 'absolute',
top: 5,
left: 5,
zIndex: 3,
opacity: hoveredImageKey === `collect-${a.id}` ? 1 : 0,
pointerEvents: hoveredImageKey === `collect-${a.id}` ? 'auto' : 'none',
transition: 'opacity 0.15s',
background: 'rgba(255,255,255,0.94)',
}}
/>
</Popconfirm>
<span
title="勾选为参考底图"
onClick={() => toggleKey(a.id)}
style={{
position: 'absolute',
top: 6,
right: 6,
width: 20,
height: 20,
borderRadius: '50%',
background: on ? '#1677ff' : 'rgba(255,255,255,0.9)',
color: on ? '#fff' : '#bbb',
border: '1px solid #d9d9d9',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: 12,
zIndex: 2,
userSelect: 'none',
}}
>
✓
</span>
{a.status !== 'uploaded' && (
<Tag
color={a.status === 'failed' ? 'red' : 'default'}
style={{ position: 'absolute', bottom: 6, left: 6, margin: 0, zIndex: 2 }}
>
{a.status === 'failed' ? '转存失败' : '转存中'}
</Tag>
)}
{/* AI 生图:半透明悬浮在图片右下角 */}
<Button
size="small"
type="text"
onClick={() => setGenModal({ url: assetOriginalUrl(a), name: a.variant_name || a.id })}
style={{
position: 'absolute',
right: 5,
bottom: 5,
zIndex: 2,
background: 'rgba(0,0,0,0.58)',
color: '#fff',
borderColor: 'transparent',
fontSize: 11,
}}
>
AI 生图
</Button>
</div>
<div
title={a.variant_name || assetName(a)}
style={{
fontSize: 11,
color: '#999',
marginTop: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{a.variant_name || assetName(a)}
</div>
</div>
);
})}
</div>
</div>
))}
</Image.PreviewGroup>
<CollectionImageGroups
groups={displayGroups.map((group) => ({ key: group, assets: groupImages(group) }))}
selectedKeys={selectedKeys}
preview={imagePreviewConfig}
imageUrl={assetUrl}
imageName={assetName}
onToggle={toggleKey}
onToggleGroup={toggleGroup}
onDelete={(assetId) => void handleDeleteAsset(assetId)}
onEdit={(asset) => setGenModal({ url: assetOriginalUrl(asset), name: asset.variant_name || asset.id })}
/>
)}
</Card>
</div>
@@ -1538,137 +1267,17 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
) : null}
</Space>
</div>
<Image.PreviewGroup preview={imagePreviewConfig}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: 10, width: '100%' }}>
{suiteResultImages.map((img) => {
const selected = selectedResultKeys.has(img.key);
return (
<div key={img.key} style={{ minWidth: 0 }} title={img.name}>
<div
onMouseEnter={() => setHoveredImageKey(img.key)}
onMouseLeave={() => setHoveredImageKey(null)}
style={{
position: 'relative',
border: `2px solid ${selected ? '#1677ff' : 'transparent'}`,
borderRadius: 8,
padding: 2,
background: selected ? 'rgba(22,119,255,0.06)' : undefined,
aspectRatio: '3 / 4',
overflow: 'hidden',
}}
>
<Image
src={img.url}
width="100%"
height="100%"
style={{ objectFit: 'cover', borderRadius: 6, display: 'block' }}
/>
{img.assetId && (
<Popconfirm
title="删除图片"
description="确认删除这张已生成的图片?"
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => handleDeleteAsset(img.assetId!, [img.key, `asset-${img.assetId}`])}
>
<Button
size="small"
danger
type="text"
icon={<DeleteOutlined />}
title="删除这张图片"
onClick={(e) => e.stopPropagation()}
style={{
position: 'absolute', top: 5, left: 5, zIndex: 3,
opacity: hoveredImageKey === img.key ? 1 : 0,
pointerEvents: hoveredImageKey === img.key ? 'auto' : 'none',
transition: 'opacity 0.15s', background: 'rgba(255,255,255,0.94)',
}}
/>
</Popconfirm>
)}
<span
title="勾选导出"
onClick={() => toggleResultKey(img.key)}
style={{
position: 'absolute', top: 5, right: 5, width: 20, height: 20,
borderRadius: '50%', background: selected ? '#1677ff' : 'rgba(255,255,255,0.9)',
color: selected ? '#fff' : '#bbb', border: '1px solid #d9d9d9', display: 'flex',
alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: 12,
zIndex: 2, userSelect: 'none',
}}
>
✓
</span>
<Button
size="small"
type="text"
icon={<DownloadOutlined />}
title="下载这张图片"
loading={downloadingResultKeys.has(img.key)}
onClick={(e) => {
e.stopPropagation();
void handleDownloadResultImage(img);
}}
style={{
position: 'absolute', bottom: 5, left: 5, zIndex: 3,
opacity: hoveredImageKey === img.key ? 1 : 0,
pointerEvents: hoveredImageKey === img.key ? 'auto' : 'none',
transition: 'opacity 0.15s', background: 'rgba(255,255,255,0.94)',
}}
/>
<Button
size="small"
type="text"
title="基于这张图再次 AI 编辑"
onClick={(e) => {
e.stopPropagation();
setGenModal({
url: assetById.get(img.assetId || '')?.original_url || img.url,
name: img.name,
afterAssetId: img.assetId,
});
}}
style={{
position: 'absolute', bottom: 5, right: 5, zIndex: 3,
opacity: hoveredImageKey === img.key ? 1 : 0,
pointerEvents: hoveredImageKey === img.key ? 'auto' : 'none',
transition: 'opacity 0.15s', background: 'rgba(0,0,0,0.58)', color: '#fff',
borderColor: 'transparent', fontSize: 11,
}}
>
AI 编辑
</Button>
</div>
<div
style={{ fontSize: 11, color: 'rgba(0,0,0,0.45)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
title={img.name}
>
{img.name}
</div>
</div>
);
})}
{suite.images.filter((img) => img.status === 'failed').map((img) => (
<div key={`failed-${img.type_id}-${img.name}`} style={{ minWidth: 0 }} title={img.error || img.name}>
<div
style={{
width: '100%', aspectRatio: '3 / 4', borderRadius: 6, background: '#f5f5f5', display: 'flex',
alignItems: 'center', justifyContent: 'center', color: '#ff4d4f',
}}
>
✗
</div>
<div
style={{ fontSize: 11, color: '#ff4d4f', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{img.error || img.name}
</div>
</div>
))}
</div>
</Image.PreviewGroup>
<GeneratedImageGrid
images={suiteResultImages}
selectedKeys={selectedResultKeys}
downloadingKeys={downloadingResultKeys}
preview={imagePreviewConfig}
onToggle={toggleResultKey}
onDelete={(image) => image.assetId && void handleDeleteAsset(image.assetId, [image.key, `asset-${image.assetId}`])}
onDownload={(image) => void handleDownloadResultImage(image)}
onEdit={(image) => setGenModal({ url: assetById.get(image.assetId || '')?.original_url || image.url, name: image.name, afterAssetId: image.assetId })}
/>
<FailedSuiteImages images={suite.images.filter((image) => image.status === 'failed')} />
{suite.error && <Alert type="warning" showIcon message={suite.error} style={{ marginTop: 8 }} />}
</>
)}
@@ -1681,129 +1290,18 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
(一键生成与单张生图的落库历史,刷新后仍保留 · {historyResultImages.length} 张)
</Text>
</Text>
<Image.PreviewGroup preview={imagePreviewConfig}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: 10, marginTop: 10, width: '100%' }}>
{historyResultImages.map((img) => {
const selected = selectedResultKeys.has(img.key);
return (
<div key={img.key} style={{ minWidth: 0 }}>
<div
onMouseEnter={() => setHoveredImageKey(img.key)}
onMouseLeave={() => setHoveredImageKey(null)}
style={{
position: 'relative',
border: `2px solid ${selected ? '#1677ff' : 'transparent'}`,
borderRadius: 8,
padding: 2,
background: selected ? 'rgba(22,119,255,0.06)' : undefined,
aspectRatio: '3 / 4',
overflow: 'hidden',
}}
>
<Image
src={img.url}
width="100%"
height="100%"
style={{ objectFit: 'cover', borderRadius: 6 }}
fallback="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='133'><rect width='100' height='133' fill='%23eee'/><text x='18' y='70' font-size='11' fill='%23999'>无预览</text></svg>"
/>
<Popconfirm
title="删除图片"
description="确认删除这张已生成的图片?"
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => handleDeleteAsset(img.assetId!, [img.key])}
>
<Button
size="small"
danger
type="text"
icon={<DeleteOutlined />}
title="删除这张图片"
onClick={(e) => e.stopPropagation()}
style={{
position: 'absolute', top: 5, left: 5, zIndex: 3,
opacity: hoveredImageKey === img.key ? 1 : 0,
pointerEvents: hoveredImageKey === img.key ? 'auto' : 'none',
transition: 'opacity 0.15s', background: 'rgba(255,255,255,0.94)',
}}
/>
</Popconfirm>
<span
title="勾选导出"
onClick={() => toggleResultKey(img.key)}
style={{
position: 'absolute', top: 5, right: 5, width: 20, height: 20,
borderRadius: '50%', background: selected ? '#1677ff' : 'rgba(255,255,255,0.9)',
color: selected ? '#fff' : '#bbb', border: '1px solid #d9d9d9', display: 'flex',
alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: 12,
zIndex: 2, userSelect: 'none',
}}
>
✓
</span>
<Button
size="small"
type="text"
icon={<DownloadOutlined />}
title="下载这张图片"
loading={downloadingResultKeys.has(img.key)}
onClick={(e) => {
e.stopPropagation();
void handleDownloadResultImage(img);
}}
style={{
position: 'absolute', bottom: 5, left: 5, zIndex: 3,
opacity: hoveredImageKey === img.key ? 1 : 0,
pointerEvents: hoveredImageKey === img.key ? 'auto' : 'none',
transition: 'opacity 0.15s', background: 'rgba(255,255,255,0.94)',
}}
/>
<Button
size="small"
type="text"
title="基于这张图再次 AI 编辑"
onClick={(e) => {
e.stopPropagation();
setGenModal({
url: assetById.get(img.assetId || '')?.original_url || img.url,
name: img.name,
afterAssetId: img.assetId,
});
}}
style={{
position: 'absolute', bottom: 5, right: 5, zIndex: 3,
opacity: hoveredImageKey === img.key ? 1 : 0,
pointerEvents: hoveredImageKey === img.key ? 'auto' : 'none',
transition: 'opacity 0.15s', background: 'rgba(0,0,0,0.58)', color: '#fff',
borderColor: 'transparent', fontSize: 11,
}}
>
AI 编辑
</Button>
</div>
<div
style={{
fontSize: 11,
marginTop: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={img.name}
>
{img.createdAt && (
<span style={{ color: 'rgba(0,0,0,0.45)' }}>
{new Date(img.createdAt).toLocaleTimeString()}
</span>
)}
</div>
</div>
);
})}
</div>
</Image.PreviewGroup>
<div style={{ marginTop: 10 }}>
<GeneratedImageGrid
images={historyResultImages}
selectedKeys={selectedResultKeys}
downloadingKeys={downloadingResultKeys}
preview={imagePreviewConfig}
onToggle={toggleResultKey}
onDelete={(image) => image.assetId && void handleDeleteAsset(image.assetId, [image.key])}
onDownload={(image) => void handleDownloadResultImage(image)}
onEdit={(image) => setGenModal({ url: assetById.get(image.assetId || '')?.original_url || image.url, name: image.name, afterAssetId: image.assetId })}
/>
</div>
</div>
)}
</>
@@ -1811,6 +1309,13 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
</Card>
</div>
{/* ── 7 视频制作 ── */}
<div id="section-video">
<Card title="7 视频制作">
<TrialVideoMaker productId={product.id} assets={assets} />
</Card>
</div>
{/* 单张 AI 生图弹窗 */}
<AiImageGenModal
open={genModal !== null}