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