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
+229
View File
@@ -0,0 +1,229 @@
import { useEffect, useState } from 'react';
import { DeleteOutlined, DownloadOutlined } from '@ant-design/icons';
import { Button, Image, Pagination, Popconfirm, Tag, Typography } from 'antd';
import type { ProductAsset } from '@/services/product';
import type { SuiteImageInfo } from '@/services/suite';
import { GROUP_LABELS, IMAGE_GRID_PAGE_SIZE, type ImagePreviewConfig, type ResultImage } from './trialImageTypes';
const { Text } = Typography;
const FALLBACK_IMAGE = "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>";
function useVisiblePage<T>(items: T[], page: number): { current: number; pages: number; visible: T[] } {
const pages = Math.max(1, Math.ceil(items.length / IMAGE_GRID_PAGE_SIZE));
const current = Math.min(page, pages);
return {
current,
pages,
visible: items.slice((current - 1) * IMAGE_GRID_PAGE_SIZE, current * IMAGE_GRID_PAGE_SIZE),
};
}
function GridPagination({ total, page, onChange }: { total: number; page: number; onChange: (page: number) => void }) {
if (total <= IMAGE_GRID_PAGE_SIZE) return null;
return (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 10 }}>
<Pagination
size="small"
current={page}
total={total}
pageSize={IMAGE_GRID_PAGE_SIZE}
showSizeChanger={false}
showTotal={(count) => `共 ${count} 张`}
onChange={onChange}
/>
</div>
);
}
interface CollectionImageGroupsProps {
groups: Array<{ key: string; assets: ProductAsset[] }>;
selectedKeys: Set<string>;
preview: ImagePreviewConfig;
imageUrl: (asset: ProductAsset) => string;
imageName: (asset: ProductAsset) => string;
onToggle: (assetId: string) => void;
onToggleGroup: (group: string, on: boolean) => void;
onDelete: (assetId: string) => void;
onEdit: (asset: ProductAsset) => void;
}
export function CollectionImageGroups({
groups,
selectedKeys,
preview,
imageUrl,
imageName,
onToggle,
onToggleGroup,
onDelete,
onEdit,
}: CollectionImageGroupsProps) {
const [pageByGroup, setPageByGroup] = useState<Record<string, number>>({});
return (
<Image.PreviewGroup preview={preview}>
{groups.map(({ key: group, assets }) => {
const requestedPage = pageByGroup[group] ?? 1;
const { current, visible } = useVisiblePage(assets, requestedPage);
const allSelected = assets.every((asset) => selectedKeys.has(asset.id));
return (
<section key={group} style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Text strong>
{GROUP_LABELS[group] ?? group}
<Text type="secondary" style={{ fontWeight: 400, marginLeft: 6 }}>{assets.length}</Text>
</Text>
<a style={{ fontSize: 12 }} onClick={() => onToggleGroup(group, !allSelected)}>
{allSelected ? '取消全选' : '全选'}
</a>
{group === 'main' && <Text type="secondary" style={{ fontSize: 11 }}>勾选的图片作为生图参考底图</Text>}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: 10 }}>
{visible.map((asset) => (
<CollectionImageCard
key={asset.id}
asset={asset}
selected={selectedKeys.has(asset.id)}
imageUrl={imageUrl(asset)}
imageName={imageName(asset)}
onToggle={() => onToggle(asset.id)}
onDelete={() => onDelete(asset.id)}
onEdit={() => onEdit(asset)}
/>
))}
</div>
<GridPagination
total={assets.length}
page={current}
onChange={(page) => setPageByGroup((prev) => ({ ...prev, [group]: page }))}
/>
</section>
);
})}
</Image.PreviewGroup>
);
}
function CollectionImageCard({
asset,
selected,
imageUrl,
imageName,
onToggle,
onDelete,
onEdit,
}: {
asset: ProductAsset;
selected: boolean;
imageUrl: string;
imageName: string;
onToggle: () => void;
onDelete: () => void;
onEdit: () => void;
}) {
const [hovered, setHovered] = useState(false);
return (
<div>
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{ position: 'relative', border: `2px solid ${selected ? '#1677ff' : 'transparent'}`, borderRadius: 8, padding: 2, background: selected ? 'rgba(22,119,255,0.06)' : undefined }}
>
<Image
loading="lazy"
src={imageUrl}
width="100%"
style={{ objectFit: 'cover', borderRadius: 6, aspectRatio: '1 / 1', display: 'block' }}
wrapperStyle={{ width: '100%', display: 'block' }}
fallback={FALLBACK_IMAGE}
/>
<Popconfirm title="删除图片" description="确认删除这张采集图片?" okText="删除" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={onDelete}>
<Button size="small" danger type="text" icon={<DeleteOutlined />} title="删除这张图片" onClick={(event) => event.stopPropagation()} style={{ position: 'absolute', top: 5, left: 5, zIndex: 3, opacity: hovered ? 1 : 0, pointerEvents: hovered ? 'auto' : 'none', transition: 'opacity 0.15s', background: 'rgba(255,255,255,0.94)' }} />
</Popconfirm>
<span title="勾选为参考底图" onClick={onToggle} style={{ position: 'absolute', top: 6, right: 6, 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>
{asset.status !== 'uploaded' && <Tag color={asset.status === 'failed' ? 'red' : 'default'} style={{ position: 'absolute', bottom: 6, left: 6, margin: 0, zIndex: 2 }}>{asset.status === 'failed' ? '转存失败' : '转存中'}</Tag>}
<Button size="small" type="text" onClick={onEdit} 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={asset.variant_name || imageName} style={{ fontSize: 11, color: '#999', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{asset.variant_name || imageName}</div>
</div>
);
}
interface GeneratedImageGridProps {
images: ResultImage[];
selectedKeys: Set<string>;
downloadingKeys: Set<string>;
preview: ImagePreviewConfig;
onToggle: (key: string) => void;
onDelete: (image: ResultImage) => void;
onDownload: (image: ResultImage) => void;
onEdit: (image: ResultImage) => void;
}
export function GeneratedImageGrid({ images, selectedKeys, downloadingKeys, preview, onToggle, onDelete, onDownload, onEdit }: GeneratedImageGridProps) {
const [page, setPage] = useState(1);
const { current, visible } = useVisiblePage(images, page);
useEffect(() => {
if (current !== page) setPage(current);
}, [current, page]);
return (
<>
<Image.PreviewGroup preview={preview}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: 10, width: '100%' }}>
{visible.map((image) => (
<GeneratedImageCard
key={image.key}
image={image}
selected={selectedKeys.has(image.key)}
downloading={downloadingKeys.has(image.key)}
onToggle={() => onToggle(image.key)}
onDelete={() => onDelete(image)}
onDownload={() => onDownload(image)}
onEdit={() => onEdit(image)}
/>
))}
</div>
</Image.PreviewGroup>
<GridPagination total={images.length} page={current} onChange={setPage} />
</>
);
}
function GeneratedImageCard({ image, selected, downloading, onToggle, onDelete, onDownload, onEdit }: {
image: ResultImage;
selected: boolean;
downloading: boolean;
onToggle: () => void;
onDelete: () => void;
onDownload: () => void;
onEdit: () => void;
}) {
const [hovered, setHovered] = useState(false);
return (
<div style={{ minWidth: 0 }} title={image.name}>
<div onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} 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 loading="lazy" src={image.url} width="100%" height="100%" style={{ objectFit: 'cover', borderRadius: 6, display: 'block' }} />
{image.assetId && <Popconfirm title="删除图片" description="确认删除这张已生成的图片?" okText="删除" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={onDelete}><Button size="small" danger type="text" icon={<DeleteOutlined />} title="删除这张图片" onClick={(event) => event.stopPropagation()} style={{ position: 'absolute', top: 5, left: 5, zIndex: 3, opacity: hovered ? 1 : 0, pointerEvents: hovered ? 'auto' : 'none', transition: 'opacity 0.15s', background: 'rgba(255,255,255,0.94)' }} /></Popconfirm>}
<span title="勾选导出" onClick={onToggle} 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={downloading} onClick={(event) => { event.stopPropagation(); onDownload(); }} style={{ position: 'absolute', bottom: 5, left: 5, zIndex: 3, opacity: hovered ? 1 : 0, pointerEvents: hovered ? 'auto' : 'none', transition: 'opacity 0.15s', background: 'rgba(255,255,255,0.94)' }} />
<Button size="small" type="text" title="基于这张图再次 AI 编辑" onClick={(event) => { event.stopPropagation(); onEdit(); }} style={{ position: 'absolute', bottom: 5, right: 5, zIndex: 3, opacity: hovered ? 1 : 0, pointerEvents: hovered ? '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={image.name}>{image.name}</div>
</div>
);
}
export function FailedSuiteImages({ images }: { images: SuiteImageInfo[] }) {
if (images.length === 0) return null;
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: 10, width: '100%', marginTop: 10 }}>
{images.map((image) => (
<div key={`failed-${image.item_id}`} style={{ minWidth: 0 }} title={image.error || image.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' }}>{image.error || image.name}</div>
</div>
))}
</div>
);
}
+14 -3
View File
@@ -26,9 +26,20 @@ const SECTIONS = [
{ id: 'section-collect', label: '采集图片' },
{ id: 'section-plan', label: '出图方案' },
{ id: 'section-result', label: '生成结果' },
{ id: 'section-video', label: '视频制作' },
{ id: 'section-export', label: '入库与导出' },
];
// 固定页头为 64px;额外保留内容间距,避免目标卡片标题被页头遮住。
const SECTION_SCROLL_OFFSET = 88;
function scrollToSection(id: string) {
const element = document.getElementById(id);
if (!element) return;
const top = window.scrollY + element.getBoundingClientRect().top - SECTION_SCROLL_OFFSET;
window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
}
export default function TrialPage() {
const { id } = useParams();
const navigate = useNavigate();
@@ -62,7 +73,7 @@ export default function TrialPage() {
let current = SECTIONS[0].id;
for (const s of SECTIONS) {
const el = document.getElementById(s.id);
if (el && el.getBoundingClientRect().top <= 90) current = s.id;
if (el && el.getBoundingClientRect().top <= SECTION_SCROLL_OFFSET) current = s.id;
}
setActive(current);
};
@@ -170,7 +181,7 @@ export default function TrialPage() {
{/* 4/5/6 三张卡片由 TrialSuitePanel 渲染,锚点在面板内部 */}
<TrialSuitePanel product={product} assets={assets} onRefreshAssets={load} />
<div id="section-export">
<Card title="7 入库与导出">
<Card title="8 入库与导出">
<TrialExportPanel product={product} />
</Card>
</div>
@@ -188,7 +199,7 @@ export default function TrialPage() {
style={{ borderInlineEnd: 0, background: 'transparent' }}
items={SECTIONS.map((s) => ({ key: s.id, label: s.label }))}
onClick={({ key }) => {
document.getElementById(key)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
scrollToSection(key);
}}
/>
</div>
+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}
@@ -0,0 +1,91 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { CloseOutlined, DeleteOutlined, DownloadOutlined, LoadingOutlined, ReloadOutlined, VideoCameraOutlined } from '@ant-design/icons';
import { Alert, App, Button, Card, Empty, Image, Modal, Popconfirm, Space, Tag, Typography } from 'antd';
import type { ProductAsset } from '@/services/product';
import { apiErrorMessage } from '@/services/api';
import { cancelVideoJob, createVideoJob, deleteVideoJob, listVideoJobs, retryVideoJob, videoDownloadUrl, type VideoJobInfo } from '@/services/suite';
const { Text } = Typography;
const urlOf = (asset: ProductAsset) => asset.stored_url || asset.source_url;
const nameOf = (asset: ProductAsset) => decodeURIComponent((asset.source_url || asset.stored_url || '').split('?')[0].split('/').pop() || asset.variant_name || '未命名图片');
const statusText = (status: VideoJobInfo['status']) => ({ queued: '排队中', running: '合成中', done: '已完成', partial: '部分完成', failed: '失败', cancelled: '已取消' } as const)[status];
const statusColor = (status: VideoJobInfo['status']) => status === 'done' ? 'green' : status === 'failed' ? 'red' : status === 'partial' ? 'orange' : status === 'cancelled' ? 'default' : 'processing';
function VideoCard({ job, removing, onCancel, onDelete, onRetry }: {
job: VideoJobInfo;
removing: boolean;
onCancel: (id: string) => void;
onDelete: (id: string) => void;
onRetry: (id: string) => void;
}) {
const active = ['queued', 'running'].includes(job.status);
const done = job.status === 'done' && !!job.result?.url;
const failed = ['failed', 'partial'].includes(job.status);
return <Card size="small" styles={{ body: { padding: 10 } }} style={{ minWidth: 0 }}>
<div style={{ position: 'relative', aspectRatio: '3 / 4', overflow: 'hidden', borderRadius: 6, background: done ? '#111' : '#f5f5f5' }}>
{done ? <video controls preload="metadata" style={{ width: '100%', height: '100%', display: 'block', objectFit: 'cover' }} src={job.result?.url || undefined} /> : <div style={{ height: '100%', display: 'grid', placeItems: 'center', textAlign: 'center', color: failed ? '#ff4d4f' : 'rgba(0,0,0,0.55)' }}>
<Space direction="vertical" size={6}>{active ? <LoadingOutlined spin style={{ fontSize: 24, color: '#1677ff' }} /> : <CloseOutlined style={{ fontSize: 22 }} />}<Text type={failed ? 'danger' : 'secondary'}>{active ? '合成中' : '合成失败'}</Text></Space>
</div>}
<Popconfirm
title={active ? '取消视频合成?' : '删除视频?'}
description={active ? '任务会立即移除,已产生的临时文件会清理。' : '视频文件和任务记录将永久删除。'}
okText={active ? '取消并移除' : '删除'}
cancelText="保留"
okButtonProps={{ danger: true }}
onConfirm={() => active ? onCancel(job.id) : onDelete(job.id)}
>
<Button size="small" danger type="text" loading={removing} icon={active ? <CloseOutlined /> : <DeleteOutlined />} title={active ? '取消合成' : '删除视频'} style={{ position: 'absolute', top: 5, left: 5, zIndex: 2, background: 'rgba(255,255,255,0.94)' }} />
</Popconfirm>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0, marginTop: 8 }}>
<Tag color={statusColor(job.status)} style={{ margin: 0, fontSize: 13 }}>{statusText(job.status)}</Tag>
<Text type="secondary" style={{ minWidth: 0, fontSize: 13, whiteSpace: 'nowrap' }}>{job.source_asset_ids.length} 图 · {job.duration_seconds ? `${job.duration_seconds.toFixed(1)} 秒` : `每图 ${job.slide_seconds} 秒`}</Text>
{done && <Button type="link" size="small" icon={<DownloadOutlined />} href={videoDownloadUrl(job.id)} style={{ marginLeft: 'auto', paddingInline: 0, fontSize: 13, whiteSpace: 'nowrap' }}>下载 MP4</Button>}
{failed && <Button type="link" size="small" icon={<ReloadOutlined />} onClick={() => onRetry(job.id)} style={{ marginLeft: 'auto', paddingInline: 0, fontSize: 13, whiteSpace: 'nowrap' }}>重试</Button>}
</div>
{job.error && <Text type="danger" ellipsis={{ tooltip: job.error }} style={{ display: 'block', fontSize: 12, marginTop: 5 }}>{job.error}</Text>}
</Card>;
}
export default function TrialVideoMaker({ productId, assets }: { productId: string; assets: ProductAsset[] }) {
const { message } = App.useApp();
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const [removingId, setRemovingId] = useState<string | null>(null);
const [jobs, setJobs] = useState<VideoJobInfo[]>([]);
const candidates = useMemo(() => assets.filter((asset) => asset.type === 'img' && asset.status === 'uploaded' && !!urlOf(asset)).sort((a, b) => Number(b.group_key === 'generated') - Number(a.group_key === 'generated') || (b.created_at || '').localeCompare(a.created_at || '')), [assets]);
const loadJobs = useCallback(async () => { try { setJobs(await listVideoJobs(productId)); } catch (error) { message.error(apiErrorMessage(error)); } }, [message, productId]);
useEffect(() => { void loadJobs(); }, [loadJobs]);
useEffect(() => { if (!jobs.some((job) => ['queued', 'running'].includes(job.status))) return; const timer = window.setInterval(() => void loadJobs(), 1500); return () => window.clearInterval(timer); }, [jobs, loadJobs]);
const toggle = (id: string) => setSelected((prev) => prev.includes(id) ? prev.filter((value) => value !== id) : prev.length >= 5 ? (message.warning('首版最多选择 5 张图片'), prev) : [...prev, id]);
const submit = async () => {
if (selected.length < 2) return message.warning('请至少选择 2 张图片');
setSubmitting(true);
try { await createVideoJob(productId, selected); message.success('视频已加入后台合成队列'); setSelected([]); setOpen(false); await loadJobs(); } catch (error) { message.error(apiErrorMessage(error)); } finally { setSubmitting(false); }
};
const cancel = async (id: string) => {
setRemovingId(id);
try { await cancelVideoJob(id); setJobs((prev) => prev.filter((job) => job.id !== id)); } catch (error) { message.error(apiErrorMessage(error)); } finally { setRemovingId(null); }
};
const remove = async (id: string) => {
setRemovingId(id);
try { await deleteVideoJob(id); setJobs((prev) => prev.filter((job) => job.id !== id)); } catch (error) { message.error(apiErrorMessage(error)); } finally { setRemovingId(null); }
};
const retry = async (id: string) => { try { await retryVideoJob(id); await loadJobs(); } catch (error) { message.error(apiErrorMessage(error)); } };
return <div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div><Text strong>视频制作</Text><Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>选择 2–5 张图片,合成 810×1080 无音轨轮播 MP4</Text></div>
<Button type="primary" size="small" icon={<VideoCameraOutlined />} disabled={candidates.length < 2} onClick={() => setOpen(true)}>合成视频</Button>
</div>
{jobs.filter((job) => job.status !== 'cancelled').length === 0 ? <Text type="secondary" style={{ display: 'block', marginTop: 10, fontSize: 12 }}>生成的轮播视频会保存在这里,可随时下载。</Text> : <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(230px, 1fr))', alignItems: 'start', gap: 10, marginTop: 12 }}>{jobs.filter((job) => job.status !== 'cancelled').slice(0, 8).map((job) => <VideoCard key={job.id} job={job} removing={removingId === job.id} onCancel={(id) => void cancel(id)} onDelete={(id) => void remove(id)} onRetry={(id) => void retry(id)} />)}</div>}
<Modal open={open} onCancel={() => setOpen(false)} onOk={() => void submit()} okText={`合成视频(${selected.length}/5)`} okButtonProps={{ disabled: selected.length < 2, loading: submitting }} width={900} title="选择轮播图片">
<Alert type="info" showIcon message="AI 生成图排在前面;点击图片按播放顺序选择。" style={{ marginBottom: 12 }} />
{candidates.length < 2 ? <Empty description="至少需要两张已转存图片" /> : <div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 10, maxHeight: 430, overflowY: 'auto' }}>{candidates.map((asset) => { const index = selected.indexOf(asset.id); return <div key={asset.id} onClick={() => toggle(asset.id)} style={{ cursor: 'pointer', border: `2px solid ${index >= 0 ? '#1677ff' : 'transparent'}`, borderRadius: 7, padding: 2, position: 'relative' }}><Image preview={false} loading="lazy" src={urlOf(asset)} width="100%" style={{ aspectRatio: '3 / 4', objectFit: 'cover', borderRadius: 5 }} />{index >= 0 && <span style={{ position: 'absolute', top: 6, right: 6, background: '#1677ff', color: '#fff', borderRadius: 12, minWidth: 22, textAlign: 'center', lineHeight: '22px' }}>{index + 1}</span>}<div style={{ fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{asset.group_key === 'generated' ? 'AI 图 · ' : ''}{nameOf(asset)}</div></div>; })}</div>}
{selected.length > 0 && <Text type="secondary" style={{ display: 'block', marginTop: 10 }}>播放顺序即选中序号;取消后重新选择可调整顺序。</Text>}
</Modal>
</div>;
}
@@ -0,0 +1,123 @@
import { Alert, Button, Checkbox, Input, InputNumber, Popconfirm, Radio, Space, Typography } from 'antd';
import type { SuiteInfo, WatermarkPayload } from '@/services/suite';
const { Text } = Typography;
interface Props {
watermark: WatermarkPayload;
watermarkJob: SuiteInfo | null;
applying: boolean;
selectedCollectionCount: number;
selectedResultCount: number;
selectedWatermarkedCollectionCount: number;
selectedWatermarkedResultCount: number;
onPatch: (patch: Partial<WatermarkPayload>) => void;
onApplyCollection: () => void;
onApplyResult: () => void;
onRemoveCollection: () => void;
onRemoveResult: () => void;
onCancelJob: () => void;
onRetryJob: () => void;
onRetryItem: (itemId: string) => void;
}
export default function TrialWatermarkSettings({
watermark,
watermarkJob,
applying,
selectedCollectionCount,
selectedResultCount,
selectedWatermarkedCollectionCount,
selectedWatermarkedResultCount,
onPatch,
onApplyCollection,
onApplyResult,
onRemoveCollection,
onRemoveResult,
onCancelJob,
onRetryJob,
onRetryItem,
}: Props) {
return (
<div style={{ width: 280 }}>
<Checkbox checked={watermark.enabled} onChange={(event) => onPatch({ enabled: event.target.checked })}>
新生成图片自动加水印
</Checkbox>
{watermarkJob && <WatermarkTaskStatus job={watermarkJob} onCancel={onCancelJob} onRetry={onRetryJob} onRetryItem={onRetryItem} />}
{watermark.enabled && (
<>
<div style={{ margin: '10px 0 6px' }}>
<Radio.Group
size="small"
optionType="button"
buttonStyle="solid"
value={watermark.type}
onChange={(event) => onPatch({ type: event.target.value })}
options={[{ value: 'image', label: '图片徽章' }, { value: 'text', label: '文字' }]}
/>
</div>
{watermark.type === 'text' && <Input size="small" value={watermark.text} placeholder="水印文字" onChange={(event) => onPatch({ text: event.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={(value) => onPatch({ opacity: value ?? 30 })} />
</div>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 6 }}>
位置固定右下角;一键与单张 AI 生图会直接按此设置合成水印,也可应用到已选采集图或生成图
</Text>
<Button type="primary" size="small" block style={{ marginTop: 10 }} loading={applying} disabled={applying || selectedCollectionCount === 0} onClick={onApplyCollection}>
为已选采集图添加水印({selectedCollectionCount})
</Button>
<Button type="primary" size="small" block style={{ marginTop: 6 }} loading={applying} disabled={applying || selectedResultCount === 0} onClick={onApplyResult}>
为已选生成图添加水印({selectedResultCount})
</Button>
</>
)}
{(selectedWatermarkedCollectionCount > 0 || selectedWatermarkedResultCount > 0) && (
<>
<Text type="secondary" style={{ display: 'block', fontSize: 11, marginTop: 10 }}>移除水印会恢复无水印原图,并删除当前水印版</Text>
<Button size="small" block style={{ marginTop: 6 }} loading={applying} disabled={applying || selectedWatermarkedCollectionCount === 0} onClick={onRemoveCollection}>
移除已选采集图水印({selectedWatermarkedCollectionCount})
</Button>
<Button size="small" block style={{ marginTop: 6 }} loading={applying} disabled={applying || selectedWatermarkedResultCount === 0} onClick={onRemoveResult}>
移除已选生成图水印({selectedWatermarkedResultCount})
</Button>
</>
)}
</div>
);
}
function WatermarkTaskStatus({ job, onCancel, onRetry, onRetryItem }: { job: SuiteInfo; onCancel: () => void; onRetry: () => void; onRetryItem: (itemId: string) => void }) {
const active = ['queued', 'running'].includes(job.status);
return (
<Alert
showIcon
type={job.status === 'done' ? 'success' : job.status === 'failed' ? 'error' : 'info'}
style={{ marginTop: 10 }}
message={
active
? `水印处理中:${job.completed ?? 0}/${job.total ?? 0}`
: job.status === 'partial'
? `水印部分完成:${job.completed ?? 0} 成功,${job.failed ?? 0} 失败`
: job.status === 'done'
? `水印已完成:${job.completed ?? job.total ?? 0} 张`
: job.status === 'cancelled'
? '水印任务已取消'
: '水印任务失败'
}
description={
<Space direction="vertical" size={4} style={{ width: '100%' }}>
{job.error && <Text type="secondary" style={{ fontSize: 11 }}>{job.error}</Text>}
{['partial', 'failed'].includes(job.status) && <Button size="small" onClick={onRetry}>重试所有失败项</Button>}
{active && <Popconfirm title="取消水印任务" description="尚未处理的图片将停止;已经完成的图片会保留水印版。" okText="取消任务" cancelText="继续处理" okButtonProps={{ danger: true }} onConfirm={onCancel}><Button size="small" danger>取消水印任务</Button></Popconfirm>}
{job.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={() => onRetryItem(item.item_id)}>重试</Button>
</Space>
))}
</Space>
}
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { ImageProps } from 'antd';
export const GROUP_LABELS: Record<string, string> = {
main: '主图',
sku: 'SKU 图',
detail: '详情图',
generated: '生成图',
upload: '手动上传',
param: '参数图',
video: '视频',
};
export const DISPLAY_GROUPS = ['main', 'sku', 'detail', 'generated', 'upload'];
export const IMAGE_GRID_PAGE_SIZE = 24;
export type ImagePreviewConfig = Pick<
NonNullable<Exclude<ImageProps['preview'], boolean>>,
'actionsRender'
>;
export interface ResultImage {
key: string;
url: string;
name: string;
groupName: string;
variantName: string | null;
assetId?: string;
source: 'suite' | 'history';
createdAt?: string;
}
+42
View File
@@ -266,3 +266,45 @@ export async function uploadProductAsset(
const res = await apiClient.post('/materials/bytes', form);
return res.data as { asset_id: string; status: string };
}
export interface VideoJobInfo {
id: string;
kind: 'slideshow_video';
status: 'queued' | 'running' | 'done' | 'partial' | 'failed' | 'cancelled';
total: number;
completed: number;
failed: number;
source_asset_ids: string[];
slide_seconds: number;
duration_seconds?: number | null;
width: number;
height: number;
file_size_bytes?: number | null;
created_at?: string | null;
error?: string | null;
result?: { item_id: string; status: string; url?: string | null; error?: string | null } | null;
}
export function createVideoJob(productId: string, assetIds: string[]) {
return api.post<{ job_id: string }>(`/products/${productId}/video-jobs`, { asset_ids: assetIds });
}
export function listVideoJobs(productId: string, config?: AxiosRequestConfig) {
return api.get<VideoJobInfo[]>(`/products/${productId}/video-jobs`, config);
}
export function cancelVideoJob(jobId: string) {
return api.post<{ deleted: boolean }>(`/video-jobs/${jobId}/cancel`);
}
export function retryVideoJob(jobId: string) {
return api.post<VideoJobInfo>(`/video-jobs/${jobId}/retry`);
}
export function deleteVideoJob(jobId: string) {
return api.delete<{ deleted: boolean }>(`/video-jobs/${jobId}`);
}
export function videoDownloadUrl(jobId: string) {
return `/api/video-jobs/${jobId}/download`;
}