Codex/ecommerce history sync #29
@@ -49,6 +49,7 @@ import EcommerceVideoHistoryPanel from "./panels/EcommerceVideoHistoryPanel";
|
|||||||
import ProductSetHostingModal from "./panels/ProductSetHostingModal";
|
import ProductSetHostingModal from "./panels/ProductSetHostingModal";
|
||||||
import ProductSetPreviewModal, { type ProductSetPreviewSelection } from "./panels/ProductSetPreviewModal";
|
import ProductSetPreviewModal, { type ProductSetPreviewSelection } from "./panels/ProductSetPreviewModal";
|
||||||
import CommandHistorySidebar from "./panels/CommandHistorySidebar";
|
import CommandHistorySidebar from "./panels/CommandHistorySidebar";
|
||||||
|
import WatermarkToolPage from "./panels/WatermarkToolPage";
|
||||||
import EcommerceDetailPanel from "./panels/EcommerceDetailPanel";
|
import EcommerceDetailPanel from "./panels/EcommerceDetailPanel";
|
||||||
import EcommerceSetPanel from "./panels/EcommerceSetPanel";
|
import EcommerceSetPanel from "./panels/EcommerceSetPanel";
|
||||||
import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel";
|
import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel";
|
||||||
@@ -58,16 +59,23 @@ import { ecommerceOssScopes, saveUnifiedEcommerceGenerationRecord, deleteEcommer
|
|||||||
import { downloadResultAsset } from "../workbench/workbenchDownload";
|
import { downloadResultAsset } from "../workbench/workbenchDownload";
|
||||||
import type { CloneOutputKey, ProductSetOutputKey } from "./utils/platformRules";
|
import type { CloneOutputKey, ProductSetOutputKey } from "./utils/platformRules";
|
||||||
import {
|
import {
|
||||||
|
buildHistoryTurnFromRecord,
|
||||||
cloneLatestSettingStorageKey,
|
cloneLatestSettingStorageKey,
|
||||||
defaultCloneDetailModuleIds,
|
defaultCloneDetailModuleIds,
|
||||||
defaultCloneSetCounts,
|
defaultCloneSetCounts,
|
||||||
ecommerceHistoryStorageKey,
|
ecommerceHistoryStorageKey,
|
||||||
|
getTurnResults,
|
||||||
isCloneImageItem,
|
isCloneImageItem,
|
||||||
isCloneResult,
|
isCloneResult,
|
||||||
isCloneSavedSetting,
|
isCloneSavedSetting,
|
||||||
|
isEcommerceHistoryRecord,
|
||||||
|
normalizeEcommerceHistoryRecord,
|
||||||
|
normalizeEcommerceHistoryTurn,
|
||||||
readCloneLatestSetting,
|
readCloneLatestSetting,
|
||||||
|
readEcommerceHistoryRecords,
|
||||||
removeFilePayloadFromImages,
|
removeFilePayloadFromImages,
|
||||||
writeCloneLatestSetting,
|
writeCloneLatestSetting,
|
||||||
|
writeEcommerceHistoryRecords,
|
||||||
} from "./utils/clonePersistence";
|
} from "./utils/clonePersistence";
|
||||||
import type {
|
import type {
|
||||||
CloneImageItem,
|
CloneImageItem,
|
||||||
@@ -78,6 +86,9 @@ import type {
|
|||||||
CloneSavedSetting,
|
CloneSavedSetting,
|
||||||
CloneSetCountKey,
|
CloneSetCountKey,
|
||||||
CloneVideoQualityKey,
|
CloneVideoQualityKey,
|
||||||
|
EcommerceHistoryRecord,
|
||||||
|
EcommerceHistoryStatus,
|
||||||
|
EcommerceHistoryTurn,
|
||||||
} from "./utils/clonePersistence";
|
} from "./utils/clonePersistence";
|
||||||
|
|
||||||
const smartCutoutColorPresets = [
|
const smartCutoutColorPresets = [
|
||||||
@@ -296,52 +307,6 @@ interface PreviewTouchGesture {
|
|||||||
startCenter: { x: number; y: number };
|
startCenter: { x: number; y: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
type EcommerceHistoryStatus = "generating" | "done" | "failed";
|
|
||||||
|
|
||||||
interface EcommerceHistoryTurn {
|
|
||||||
id: string;
|
|
||||||
createdAt: number;
|
|
||||||
status: EcommerceHistoryStatus;
|
|
||||||
errorMessage?: string;
|
|
||||||
output: CloneOutputKey;
|
|
||||||
platform: string;
|
|
||||||
market: string;
|
|
||||||
language: string;
|
|
||||||
ratio: string;
|
|
||||||
requirement: string;
|
|
||||||
productImages: CloneImageItem[];
|
|
||||||
results: CloneResult[];
|
|
||||||
setResultImages: string[];
|
|
||||||
setCounts: Record<CloneSetCountKey, number>;
|
|
||||||
detailModules: string[];
|
|
||||||
modelScenes: string[];
|
|
||||||
referenceImages: CloneImageItem[];
|
|
||||||
replicateLevel: CloneReplicateLevelKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EcommerceHistoryRecord {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
createdAt: number;
|
|
||||||
status?: EcommerceHistoryStatus;
|
|
||||||
errorMessage?: string;
|
|
||||||
output: CloneOutputKey;
|
|
||||||
platform: string;
|
|
||||||
market: string;
|
|
||||||
language: string;
|
|
||||||
ratio: string;
|
|
||||||
requirement: string;
|
|
||||||
productImages: CloneImageItem[];
|
|
||||||
results: CloneResult[];
|
|
||||||
setResultImages: string[];
|
|
||||||
setCounts: Record<CloneSetCountKey, number>;
|
|
||||||
detailModules: string[];
|
|
||||||
modelScenes: string[];
|
|
||||||
referenceImages: CloneImageItem[];
|
|
||||||
replicateLevel: CloneReplicateLevelKey;
|
|
||||||
turns?: EcommerceHistoryTurn[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EcommerceImagePromptOptions {
|
interface EcommerceImagePromptOptions {
|
||||||
gender?: string;
|
gender?: string;
|
||||||
age?: string;
|
age?: string;
|
||||||
@@ -1436,127 +1401,6 @@ function notifyRejectedImages(files: File[]): File[] {
|
|||||||
return accepted;
|
return accepted;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEcommerceHistoryRecord(item: unknown): item is EcommerceHistoryRecord {
|
|
||||||
const candidate = item as Partial<EcommerceHistoryRecord>;
|
|
||||||
return (
|
|
||||||
typeof candidate.id === "string" &&
|
|
||||||
typeof candidate.title === "string" &&
|
|
||||||
typeof candidate.createdAt === "number" &&
|
|
||||||
typeof candidate.output === "string" &&
|
|
||||||
typeof candidate.platform === "string" &&
|
|
||||||
typeof candidate.market === "string" &&
|
|
||||||
typeof candidate.language === "string" &&
|
|
||||||
typeof candidate.ratio === "string" &&
|
|
||||||
typeof candidate.requirement === "string" &&
|
|
||||||
Array.isArray(candidate.productImages) &&
|
|
||||||
candidate.productImages.every(isCloneImageItem) &&
|
|
||||||
Array.isArray(candidate.results) &&
|
|
||||||
candidate.results.every(isCloneResult)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTurnResults(turn: EcommerceHistoryTurn): CloneResult[] {
|
|
||||||
if (turn.results?.length) return turn.results.filter((item) => item.src);
|
|
||||||
if (turn.output !== "set") return [];
|
|
||||||
return (turn.setResultImages ?? [])
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((src, index) => ({ id: `${turn.id}-set-${index}`, src, label: `套图 ${index + 1}` }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildHistoryTurnFromRecord(record: EcommerceHistoryRecord): EcommerceHistoryTurn {
|
|
||||||
return {
|
|
||||||
id: `${record.id}-turn-initial`,
|
|
||||||
createdAt: record.createdAt,
|
|
||||||
status: record.status ?? "done",
|
|
||||||
errorMessage: record.status === "failed" ? record.errorMessage : undefined,
|
|
||||||
output: record.output,
|
|
||||||
platform: record.platform,
|
|
||||||
market: record.market,
|
|
||||||
language: record.language,
|
|
||||||
ratio: record.ratio,
|
|
||||||
requirement: record.requirement,
|
|
||||||
productImages: record.productImages ?? [],
|
|
||||||
results: record.results ?? [],
|
|
||||||
setResultImages: record.setResultImages ?? [],
|
|
||||||
setCounts: record.setCounts ?? defaultCloneSetCounts,
|
|
||||||
detailModules: record.detailModules ?? defaultCloneDetailModuleIds,
|
|
||||||
modelScenes: record.modelScenes ?? [],
|
|
||||||
referenceImages: record.referenceImages ?? [],
|
|
||||||
replicateLevel: record.replicateLevel ?? "high",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeEcommerceHistoryTurn(turn: EcommerceHistoryTurn, fallback: EcommerceHistoryRecord, index: number): EcommerceHistoryTurn {
|
|
||||||
const status = turn.status ?? fallback.status ?? "done";
|
|
||||||
return {
|
|
||||||
id: typeof turn.id === "string" && turn.id ? turn.id : `${fallback.id}-turn-${index + 1}`,
|
|
||||||
createdAt: typeof turn.createdAt === "number" ? turn.createdAt : fallback.createdAt,
|
|
||||||
status,
|
|
||||||
errorMessage: status === "failed" ? turn.errorMessage ?? fallback.errorMessage : undefined,
|
|
||||||
output: turn.output ?? fallback.output,
|
|
||||||
platform: turn.platform ?? fallback.platform,
|
|
||||||
market: turn.market ?? fallback.market,
|
|
||||||
language: turn.language ?? fallback.language,
|
|
||||||
ratio: turn.ratio ?? fallback.ratio,
|
|
||||||
requirement: turn.requirement ?? fallback.requirement,
|
|
||||||
productImages: removeFilePayloadFromImages(Array.isArray(turn.productImages) ? turn.productImages : fallback.productImages),
|
|
||||||
results: Array.isArray(turn.results) ? turn.results.filter(isCloneResult) : [],
|
|
||||||
setResultImages: Array.isArray(turn.setResultImages) ? turn.setResultImages.filter(Boolean) : [],
|
|
||||||
setCounts: turn.setCounts ?? fallback.setCounts ?? defaultCloneSetCounts,
|
|
||||||
detailModules: turn.detailModules ?? fallback.detailModules ?? defaultCloneDetailModuleIds,
|
|
||||||
modelScenes: turn.modelScenes ?? fallback.modelScenes ?? [],
|
|
||||||
referenceImages: removeFilePayloadFromImages(Array.isArray(turn.referenceImages) ? turn.referenceImages : fallback.referenceImages ?? []),
|
|
||||||
replicateLevel: turn.replicateLevel ?? fallback.replicateLevel ?? "high",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord): EcommerceHistoryRecord {
|
|
||||||
const status = record.status ?? "done";
|
|
||||||
const baseRecord = {
|
|
||||||
...record,
|
|
||||||
status,
|
|
||||||
errorMessage: status === "failed" ? record.errorMessage : undefined,
|
|
||||||
productImages: removeFilePayloadFromImages(record.productImages),
|
|
||||||
referenceImages: removeFilePayloadFromImages(record.referenceImages ?? []),
|
|
||||||
results: record.results ?? [],
|
|
||||||
setResultImages: record.setResultImages ?? [],
|
|
||||||
setCounts: record.setCounts ?? defaultCloneSetCounts,
|
|
||||||
detailModules: record.detailModules ?? defaultCloneDetailModuleIds,
|
|
||||||
modelScenes: record.modelScenes ?? [],
|
|
||||||
replicateLevel: record.replicateLevel ?? "high",
|
|
||||||
};
|
|
||||||
const rawTurns = Array.isArray(record.turns) && record.turns.length
|
|
||||||
? record.turns
|
|
||||||
: [buildHistoryTurnFromRecord(baseRecord)];
|
|
||||||
const turns = rawTurns.map((turn, index) => normalizeEcommerceHistoryTurn(turn, baseRecord, index));
|
|
||||||
return {
|
|
||||||
...baseRecord,
|
|
||||||
turns,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function readEcommerceHistoryRecords() {
|
|
||||||
if (typeof window === "undefined") return [];
|
|
||||||
try {
|
|
||||||
const rawValue = window.localStorage.getItem(ecommerceHistoryStorageKey);
|
|
||||||
if (!rawValue) return [];
|
|
||||||
const parsedValue: unknown = JSON.parse(rawValue);
|
|
||||||
if (!Array.isArray(parsedValue)) return [];
|
|
||||||
return parsedValue
|
|
||||||
.filter(isEcommerceHistoryRecord)
|
|
||||||
.map(normalizeEcommerceHistoryRecord)
|
|
||||||
.sort((a, b) => b.createdAt - a.createdAt)
|
|
||||||
.slice(0, 30);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeEcommerceHistoryRecords(records: EcommerceHistoryRecord[]) {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
window.localStorage.setItem(ecommerceHistoryStorageKey, JSON.stringify(records.map(normalizeEcommerceHistoryRecord).slice(0, 30)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampCloneVideoDuration(value: number) {
|
function clampCloneVideoDuration(value: number) {
|
||||||
return Math.min(cloneVideoDurationMax, Math.max(cloneVideoDurationMin, Math.round(value)));
|
return Math.min(cloneVideoDurationMax, Math.max(cloneVideoDurationMin, Math.round(value)));
|
||||||
}
|
}
|
||||||
@@ -7112,184 +6956,23 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const watermarkPreview = (
|
const watermarkPreview = (
|
||||||
<main key="watermark" className="ecom-watermark-page ecom-tool-page-enter" aria-label="去水印">
|
<WatermarkToolPage
|
||||||
<input
|
inputRef={watermarkInputRef}
|
||||||
ref={watermarkInputRef}
|
urlInputRef={watermarkUrlInputRef}
|
||||||
type="file"
|
image={watermarkImage}
|
||||||
accept="image/*"
|
isDragging={isWatermarkDragging}
|
||||||
className="ecom-command-hidden-file"
|
status={watermarkStatus}
|
||||||
onChange={handleWatermarkUpload}
|
progress={watermarkProgress}
|
||||||
aria-label="上传去水印图片"
|
resultUrl={watermarkResultUrl}
|
||||||
/>
|
onUpload={handleWatermarkUpload}
|
||||||
<aside className="ecom-watermark-side">
|
onDrop={handleWatermarkDrop}
|
||||||
<header className="ecom-quick-set-panel-head ecom-watermark-panel-head">
|
onDraggingChange={setIsWatermarkDragging}
|
||||||
<strong className="ecom-quick-set-page-title">去水印</strong>
|
onRemoveImage={removeWatermarkImage}
|
||||||
<button type="button" className="ecom-quick-set-back" onClick={closeWatermarkRemovalPage}>首页</button>
|
onUrlImport={handleWatermarkUrlImport}
|
||||||
<button type="button" className="ecom-quick-set-back" onClick={closeWatermarkRemovalPage}>上一页</button>
|
onGenerate={handleWatermarkGenerate}
|
||||||
</header>
|
onDownload={handleWatermarkDownload}
|
||||||
<p className="ecom-watermark-intro">上传商品素材,快速清理画面中的水印、文字和瑕疵。</p>
|
onClose={closeWatermarkRemovalPage}
|
||||||
<section className="ecom-watermark-panel">
|
/>
|
||||||
<header>
|
|
||||||
<strong>上传素材</strong>
|
|
||||||
<span>{watermarkImage ? "已上传" : "待上传"}</span>
|
|
||||||
</header>
|
|
||||||
<div
|
|
||||||
className={`ecom-watermark-upload-card${isWatermarkDragging ? " is-dragging" : ""}${watermarkImage ? " has-image" : ""}`}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => watermarkInputRef.current?.click()}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
watermarkInputRef.current?.click();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDragEnter={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setIsWatermarkDragging(true);
|
|
||||||
}}
|
|
||||||
onDragOver={(event) => event.preventDefault()}
|
|
||||||
onDragLeave={() => setIsWatermarkDragging(false)}
|
|
||||||
onDrop={handleWatermarkDrop}
|
|
||||||
>
|
|
||||||
{watermarkImage ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ecom-watermark-remove"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
removeWatermarkImage();
|
|
||||||
}}
|
|
||||||
aria-label="删除素材"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
<figure>
|
|
||||||
<img src={watermarkImage.src} alt={watermarkImage.name} />
|
|
||||||
</figure>
|
|
||||||
<div>
|
|
||||||
<strong>{watermarkImage.name}</strong>
|
|
||||||
<span>{watermarkImage.format || "PNG / JPG / WebP"}</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CloudUploadOutlined />
|
|
||||||
<strong>上传含水印图片</strong>
|
|
||||||
<span>支持 PNG / JPG / WebP,拖拽或点击上传</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="ecom-watermark-url-row">
|
|
||||||
<input
|
|
||||||
ref={watermarkUrlInputRef}
|
|
||||||
placeholder="粘贴图片 URL"
|
|
||||||
aria-label="粘贴图片 URL"
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter") void handleWatermarkUrlImport();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button type="button" onClick={() => void handleWatermarkUrlImport()}>导入</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="ecom-watermark-panel">
|
|
||||||
<strong>处理说明</strong>
|
|
||||||
<p>优先保留商品主体、材质和边缘细节,适合电商主图、详情图和社媒素材清理。</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ecom-watermark-primary"
|
|
||||||
onClick={handleWatermarkGenerate}
|
|
||||||
disabled={!watermarkImage || watermarkStatus === "processing"}
|
|
||||||
>
|
|
||||||
{watermarkStatus === "processing" ? <LoadingOutlined /> : <FileImageOutlined />}
|
|
||||||
{watermarkStatus === "processing" ? "处理中" : "开始去水印"}
|
|
||||||
</button>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section className="ecom-watermark-workspace">
|
|
||||||
{!watermarkImage ? (
|
|
||||||
<div
|
|
||||||
className={`ecom-watermark-dropzone${isWatermarkDragging ? " is-dragging" : ""}`}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => watermarkInputRef.current?.click()}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
watermarkInputRef.current?.click();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDragEnter={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setIsWatermarkDragging(true);
|
|
||||||
}}
|
|
||||||
onDragOver={(event) => event.preventDefault()}
|
|
||||||
onDragLeave={() => setIsWatermarkDragging(false)}
|
|
||||||
onDrop={handleWatermarkDrop}
|
|
||||||
>
|
|
||||||
<CloudUploadOutlined />
|
|
||||||
<strong>点击或拖拽上传图片</strong>
|
|
||||||
<span>支持 PNG / JPG / WebP,上传含水印图片后点击开始去水印</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="ecom-watermark-grid">
|
|
||||||
<article className="ecom-watermark-preview-card">
|
|
||||||
<span>原图</span>
|
|
||||||
<img src={watermarkImage.src} alt="原图" />
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article className="ecom-watermark-preview-card">
|
|
||||||
<span>去水印结果</span>
|
|
||||||
{watermarkStatus === "processing" ? (
|
|
||||||
<div className="ecom-watermark-processing" role="status" aria-live="polite">
|
|
||||||
<LoadingOutlined />
|
|
||||||
<strong>正在去水印</strong>
|
|
||||||
<em>AI 正在清理图片中的水印和文字</em>
|
|
||||||
<div className="ecom-quick-set-progress">
|
|
||||||
<div className="ecom-quick-set-progress-bar" style={{ width: `${Math.round(watermarkProgress)}%` }} />
|
|
||||||
</div>
|
|
||||||
<em className="ecom-quick-set-progress-text">{Math.round(watermarkProgress)}%</em>
|
|
||||||
</div>
|
|
||||||
) : watermarkStatus === "done" && watermarkResultUrl ? (
|
|
||||||
<>
|
|
||||||
<img src={watermarkResultUrl} alt="去水印结果" />
|
|
||||||
<button type="button" className="ecom-watermark-zoom" aria-label="查看大图">
|
|
||||||
<QuestionCircleOutlined />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : watermarkStatus === "failed" ? (
|
|
||||||
<div className="ecom-watermark-empty">
|
|
||||||
<FrownOutlined />
|
|
||||||
<strong>去水印失败</strong>
|
|
||||||
<em>请检查网络或重试,如余额不足请先充值</em>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="ecom-watermark-empty">
|
|
||||||
<FileImageOutlined />
|
|
||||||
<strong>等待处理</strong>
|
|
||||||
<em>点击开始去水印后显示结果</em>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="ecom-watermark-actions">
|
|
||||||
<button type="button" onClick={() => toast.success("已加入资产库")} disabled={watermarkStatus !== "done"}>
|
|
||||||
<FolderOpenOutlined />
|
|
||||||
加入资产库
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={handleWatermarkDownload} disabled={watermarkStatus !== "done"}>
|
|
||||||
<CloudUploadOutlined />
|
|
||||||
下载图片
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const translateLanguageOptions = [
|
const translateLanguageOptions = [
|
||||||
|
|||||||
@@ -2,16 +2,10 @@ import { DeleteOutlined, MenuFoldOutlined, MenuUnfoldOutlined, ReloadOutlined }
|
|||||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||||
import type { EcommerceHistoryRecord } from "../utils/clonePersistence";
|
import type { EcommerceHistoryRecord } from "../utils/clonePersistence";
|
||||||
|
|
||||||
// 页面内的 EcommerceHistoryRecord 比 clonePersistence 版本更丰富(多了 status/errorMessage/turns),
|
|
||||||
// 这里用 intersection 补齐侧栏实际用到的字段,避免依赖页面的内部类型。
|
|
||||||
type HistoryRecord = EcommerceHistoryRecord & {
|
|
||||||
status?: "generating" | "done" | "failed";
|
|
||||||
};
|
|
||||||
|
|
||||||
interface CommandHistorySidebarProps {
|
interface CommandHistorySidebarProps {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
showBackdrop: boolean;
|
showBackdrop: boolean;
|
||||||
records: HistoryRecord[];
|
records: EcommerceHistoryRecord[];
|
||||||
activeRecordId: string | null;
|
activeRecordId: string | null;
|
||||||
isRefreshing: boolean;
|
isRefreshing: boolean;
|
||||||
refreshMessage: string | null;
|
refreshMessage: string | null;
|
||||||
@@ -23,7 +17,7 @@ interface CommandHistorySidebarProps {
|
|||||||
onCollapse: () => void;
|
onCollapse: () => void;
|
||||||
onNewConversation: () => void;
|
onNewConversation: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onOpenRecord: (record: HistoryRecord) => void;
|
onOpenRecord: (record: EcommerceHistoryRecord) => void;
|
||||||
onDeleteRecord: (recordId: string, event: ReactMouseEvent) => void;
|
onDeleteRecord: (recordId: string, event: ReactMouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import {
|
||||||
|
CloudUploadOutlined,
|
||||||
|
FileImageOutlined,
|
||||||
|
FolderOpenOutlined,
|
||||||
|
FrownOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
|
QuestionCircleOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import type { ChangeEvent, DragEvent, KeyboardEvent, RefObject } from "react";
|
||||||
|
import { toast } from "../../../components/toast/toastStore";
|
||||||
|
|
||||||
|
export interface WatermarkImageItem {
|
||||||
|
src: string;
|
||||||
|
name: string;
|
||||||
|
format: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WatermarkStatus = "idle" | "processing" | "done" | "failed";
|
||||||
|
|
||||||
|
interface WatermarkToolPageProps {
|
||||||
|
inputRef: RefObject<HTMLInputElement>;
|
||||||
|
urlInputRef: RefObject<HTMLInputElement>;
|
||||||
|
image: WatermarkImageItem | null;
|
||||||
|
isDragging: boolean;
|
||||||
|
status: WatermarkStatus;
|
||||||
|
progress: number;
|
||||||
|
resultUrl: string | null;
|
||||||
|
onUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onDrop: (event: DragEvent<HTMLDivElement>) => void;
|
||||||
|
onDraggingChange: (dragging: boolean) => void;
|
||||||
|
onRemoveImage: () => void;
|
||||||
|
onUrlImport: () => void;
|
||||||
|
onGenerate: () => void;
|
||||||
|
onDownload: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去水印工具页面:上传含水印图片 → AI 清理 → 预览/下载结果。
|
||||||
|
// 从 EcommercePage 的 watermarkPreview 抽出,状态与处理逻辑仍在父组件,本组件纯展示 + 回调。
|
||||||
|
export default function WatermarkToolPage({
|
||||||
|
inputRef,
|
||||||
|
urlInputRef,
|
||||||
|
image,
|
||||||
|
isDragging,
|
||||||
|
status,
|
||||||
|
progress,
|
||||||
|
resultUrl,
|
||||||
|
onUpload,
|
||||||
|
onDrop,
|
||||||
|
onDraggingChange,
|
||||||
|
onRemoveImage,
|
||||||
|
onUrlImport,
|
||||||
|
onGenerate,
|
||||||
|
onDownload,
|
||||||
|
onClose,
|
||||||
|
}: WatermarkToolPageProps) {
|
||||||
|
return (
|
||||||
|
<main key="watermark" className="ecom-watermark-page ecom-tool-page-enter" aria-label="去水印">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="ecom-command-hidden-file"
|
||||||
|
onChange={onUpload}
|
||||||
|
aria-label="上传去水印图片"
|
||||||
|
/>
|
||||||
|
<aside className="ecom-watermark-side">
|
||||||
|
<header className="ecom-quick-set-panel-head ecom-watermark-panel-head">
|
||||||
|
<strong className="ecom-quick-set-page-title">去水印</strong>
|
||||||
|
<button type="button" className="ecom-quick-set-back" onClick={onClose}>首页</button>
|
||||||
|
<button type="button" className="ecom-quick-set-back" onClick={onClose}>上一页</button>
|
||||||
|
</header>
|
||||||
|
<p className="ecom-watermark-intro">上传商品素材,快速清理画面中的水印、文字和瑕疵。</p>
|
||||||
|
<section className="ecom-watermark-panel">
|
||||||
|
<header>
|
||||||
|
<strong>上传素材</strong>
|
||||||
|
<span>{image ? "已上传" : "待上传"}</span>
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
className={`ecom-watermark-upload-card${isDragging ? " is-dragging" : ""}${image ? " has-image" : ""}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onKeyDown={(event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
inputRef.current?.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragEnter={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onDraggingChange(true);
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => event.preventDefault()}
|
||||||
|
onDragLeave={() => onDraggingChange(false)}
|
||||||
|
onDrop={onDrop}
|
||||||
|
>
|
||||||
|
{image ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ecom-watermark-remove"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onRemoveImage();
|
||||||
|
}}
|
||||||
|
aria-label="删除素材"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<figure>
|
||||||
|
<img src={image.src} alt={image.name} />
|
||||||
|
</figure>
|
||||||
|
<div>
|
||||||
|
<strong>{image.name}</strong>
|
||||||
|
<span>{image.format || "PNG / JPG / WebP"}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CloudUploadOutlined />
|
||||||
|
<strong>上传含水印图片</strong>
|
||||||
|
<span>支持 PNG / JPG / WebP,拖拽或点击上传</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ecom-watermark-url-row">
|
||||||
|
<input
|
||||||
|
ref={urlInputRef}
|
||||||
|
placeholder="粘贴图片 URL"
|
||||||
|
aria-label="粘贴图片 URL"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") void onUrlImport();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => void onUrlImport()}>导入</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="ecom-watermark-panel">
|
||||||
|
<strong>处理说明</strong>
|
||||||
|
<p>优先保留商品主体、材质和边缘细节,适合电商主图、详情图和社媒素材清理。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ecom-watermark-primary"
|
||||||
|
onClick={onGenerate}
|
||||||
|
disabled={!image || status === "processing"}
|
||||||
|
>
|
||||||
|
{status === "processing" ? <LoadingOutlined /> : <FileImageOutlined />}
|
||||||
|
{status === "processing" ? "处理中" : "开始去水印"}
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="ecom-watermark-workspace">
|
||||||
|
{!image ? (
|
||||||
|
<div
|
||||||
|
className={`ecom-watermark-dropzone${isDragging ? " is-dragging" : ""}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onKeyDown={(event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
inputRef.current?.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragEnter={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onDraggingChange(true);
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => event.preventDefault()}
|
||||||
|
onDragLeave={() => onDraggingChange(false)}
|
||||||
|
onDrop={onDrop}
|
||||||
|
>
|
||||||
|
<CloudUploadOutlined />
|
||||||
|
<strong>点击或拖拽上传图片</strong>
|
||||||
|
<span>支持 PNG / JPG / WebP,上传含水印图片后点击开始去水印</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ecom-watermark-grid">
|
||||||
|
<article className="ecom-watermark-preview-card">
|
||||||
|
<span>原图</span>
|
||||||
|
<img src={image.src} alt="原图" />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="ecom-watermark-preview-card">
|
||||||
|
<span>去水印结果</span>
|
||||||
|
{status === "processing" ? (
|
||||||
|
<div className="ecom-watermark-processing" role="status" aria-live="polite">
|
||||||
|
<LoadingOutlined />
|
||||||
|
<strong>正在去水印</strong>
|
||||||
|
<em>AI 正在清理图片中的水印和文字</em>
|
||||||
|
<div className="ecom-quick-set-progress">
|
||||||
|
<div className="ecom-quick-set-progress-bar" style={{ width: `${Math.round(progress)}%` }} />
|
||||||
|
</div>
|
||||||
|
<em className="ecom-quick-set-progress-text">{Math.round(progress)}%</em>
|
||||||
|
</div>
|
||||||
|
) : status === "done" && resultUrl ? (
|
||||||
|
<>
|
||||||
|
<img src={resultUrl} alt="去水印结果" />
|
||||||
|
<button type="button" className="ecom-watermark-zoom" aria-label="查看大图">
|
||||||
|
<QuestionCircleOutlined />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : status === "failed" ? (
|
||||||
|
<div className="ecom-watermark-empty">
|
||||||
|
<FrownOutlined />
|
||||||
|
<strong>去水印失败</strong>
|
||||||
|
<em>请检查网络或重试,如余额不足请先充值</em>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ecom-watermark-empty">
|
||||||
|
<FileImageOutlined />
|
||||||
|
<strong>等待处理</strong>
|
||||||
|
<em>点击开始去水印后显示结果</em>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="ecom-watermark-actions">
|
||||||
|
<button type="button" onClick={() => toast.success("已加入资产库")} disabled={status !== "done"}>
|
||||||
|
<FolderOpenOutlined />
|
||||||
|
加入资产库
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onDownload} disabled={status !== "done"}>
|
||||||
|
<CloudUploadOutlined />
|
||||||
|
下载图片
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -60,10 +60,13 @@ export interface CloneSavedSetting {
|
|||||||
requirement: string;
|
requirement: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EcommerceHistoryRecord {
|
export type EcommerceHistoryStatus = "generating" | "done" | "failed";
|
||||||
|
|
||||||
|
export interface EcommerceHistoryTurn {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
status: EcommerceHistoryStatus;
|
||||||
|
errorMessage?: string;
|
||||||
output: CloneOutputKey;
|
output: CloneOutputKey;
|
||||||
platform: string;
|
platform: string;
|
||||||
market: string;
|
market: string;
|
||||||
@@ -80,6 +83,29 @@ export interface EcommerceHistoryRecord {
|
|||||||
replicateLevel: CloneReplicateLevelKey;
|
replicateLevel: CloneReplicateLevelKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EcommerceHistoryRecord {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: number;
|
||||||
|
status?: EcommerceHistoryStatus;
|
||||||
|
errorMessage?: string;
|
||||||
|
output: CloneOutputKey;
|
||||||
|
platform: string;
|
||||||
|
market: string;
|
||||||
|
language: string;
|
||||||
|
ratio: string;
|
||||||
|
requirement: string;
|
||||||
|
productImages: CloneImageItem[];
|
||||||
|
results: CloneResult[];
|
||||||
|
setResultImages: string[];
|
||||||
|
setCounts: Record<CloneSetCountKey, number>;
|
||||||
|
detailModules: string[];
|
||||||
|
modelScenes: string[];
|
||||||
|
referenceImages: CloneImageItem[];
|
||||||
|
replicateLevel: CloneReplicateLevelKey;
|
||||||
|
turns?: EcommerceHistoryTurn[];
|
||||||
|
}
|
||||||
|
|
||||||
export const cloneLatestSettingStorageKey = "omniai.clone-ai.latest-setting";
|
export const cloneLatestSettingStorageKey = "omniai.clone-ai.latest-setting";
|
||||||
export const ecommerceHistoryStorageKey = "omniai.ecommerce.history.records";
|
export const ecommerceHistoryStorageKey = "omniai.ecommerce.history.records";
|
||||||
|
|
||||||
@@ -148,9 +174,67 @@ export function removeFilePayloadFromImages(images: CloneImageItem[]): CloneImag
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord): EcommerceHistoryRecord {
|
export function getTurnResults(turn: EcommerceHistoryTurn): CloneResult[] {
|
||||||
|
if (turn.results?.length) return turn.results.filter((item) => item.src);
|
||||||
|
if (turn.output !== "set") return [];
|
||||||
|
return (turn.setResultImages ?? [])
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((src, index) => ({ id: `${turn.id}-set-${index}`, src, label: `套图 ${index + 1}` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHistoryTurnFromRecord(record: EcommerceHistoryRecord): EcommerceHistoryTurn {
|
||||||
return {
|
return {
|
||||||
|
id: `${record.id}-turn-initial`,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
status: record.status ?? "done",
|
||||||
|
errorMessage: record.status === "failed" ? record.errorMessage : undefined,
|
||||||
|
output: record.output,
|
||||||
|
platform: record.platform,
|
||||||
|
market: record.market,
|
||||||
|
language: record.language,
|
||||||
|
ratio: record.ratio,
|
||||||
|
requirement: record.requirement,
|
||||||
|
productImages: record.productImages ?? [],
|
||||||
|
results: record.results ?? [],
|
||||||
|
setResultImages: record.setResultImages ?? [],
|
||||||
|
setCounts: record.setCounts ?? defaultCloneSetCounts,
|
||||||
|
detailModules: record.detailModules ?? defaultCloneDetailModuleIds,
|
||||||
|
modelScenes: record.modelScenes ?? [],
|
||||||
|
referenceImages: record.referenceImages ?? [],
|
||||||
|
replicateLevel: record.replicateLevel ?? "high",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEcommerceHistoryTurn(turn: EcommerceHistoryTurn, fallback: EcommerceHistoryRecord, index: number): EcommerceHistoryTurn {
|
||||||
|
const status = turn.status ?? fallback.status ?? "done";
|
||||||
|
return {
|
||||||
|
id: typeof turn.id === "string" && turn.id ? turn.id : `${fallback.id}-turn-${index + 1}`,
|
||||||
|
createdAt: typeof turn.createdAt === "number" ? turn.createdAt : fallback.createdAt,
|
||||||
|
status,
|
||||||
|
errorMessage: status === "failed" ? turn.errorMessage ?? fallback.errorMessage : undefined,
|
||||||
|
output: turn.output ?? fallback.output,
|
||||||
|
platform: turn.platform ?? fallback.platform,
|
||||||
|
market: turn.market ?? fallback.market,
|
||||||
|
language: turn.language ?? fallback.language,
|
||||||
|
ratio: turn.ratio ?? fallback.ratio,
|
||||||
|
requirement: turn.requirement ?? fallback.requirement,
|
||||||
|
productImages: removeFilePayloadFromImages(Array.isArray(turn.productImages) ? turn.productImages : fallback.productImages),
|
||||||
|
results: Array.isArray(turn.results) ? turn.results.filter(isCloneResult) : [],
|
||||||
|
setResultImages: Array.isArray(turn.setResultImages) ? turn.setResultImages.filter(Boolean) : [],
|
||||||
|
setCounts: turn.setCounts ?? fallback.setCounts ?? defaultCloneSetCounts,
|
||||||
|
detailModules: turn.detailModules ?? fallback.detailModules ?? defaultCloneDetailModuleIds,
|
||||||
|
modelScenes: turn.modelScenes ?? fallback.modelScenes ?? [],
|
||||||
|
referenceImages: removeFilePayloadFromImages(Array.isArray(turn.referenceImages) ? turn.referenceImages : fallback.referenceImages ?? []),
|
||||||
|
replicateLevel: turn.replicateLevel ?? fallback.replicateLevel ?? "high",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord): EcommerceHistoryRecord {
|
||||||
|
const status = record.status ?? "done";
|
||||||
|
const baseRecord = {
|
||||||
...record,
|
...record,
|
||||||
|
status,
|
||||||
|
errorMessage: status === "failed" ? record.errorMessage : undefined,
|
||||||
productImages: removeFilePayloadFromImages(record.productImages),
|
productImages: removeFilePayloadFromImages(record.productImages),
|
||||||
referenceImages: removeFilePayloadFromImages(record.referenceImages ?? []),
|
referenceImages: removeFilePayloadFromImages(record.referenceImages ?? []),
|
||||||
results: record.results ?? [],
|
results: record.results ?? [],
|
||||||
@@ -160,6 +244,14 @@ export function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord):
|
|||||||
modelScenes: record.modelScenes ?? [],
|
modelScenes: record.modelScenes ?? [],
|
||||||
replicateLevel: record.replicateLevel ?? "high",
|
replicateLevel: record.replicateLevel ?? "high",
|
||||||
};
|
};
|
||||||
|
const rawTurns = Array.isArray(record.turns) && record.turns.length
|
||||||
|
? record.turns
|
||||||
|
: [buildHistoryTurnFromRecord(baseRecord)];
|
||||||
|
const turns = rawTurns.map((turn, index) => normalizeEcommerceHistoryTurn(turn, baseRecord, index));
|
||||||
|
return {
|
||||||
|
...baseRecord,
|
||||||
|
turns,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readCloneLatestSetting(): CloneSavedSetting | null {
|
export function readCloneLatestSetting(): CloneSavedSetting | null {
|
||||||
|
|||||||
Reference in New Issue
Block a user