Refine seller store implementation
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user