refactor: sync clonePersistence types, extract WatermarkToolPage
This commit is contained in:
@@ -49,6 +49,7 @@ import EcommerceVideoHistoryPanel from "./panels/EcommerceVideoHistoryPanel";
|
||||
import ProductSetHostingModal from "./panels/ProductSetHostingModal";
|
||||
import ProductSetPreviewModal, { type ProductSetPreviewSelection } from "./panels/ProductSetPreviewModal";
|
||||
import CommandHistorySidebar from "./panels/CommandHistorySidebar";
|
||||
import WatermarkToolPage from "./panels/WatermarkToolPage";
|
||||
import EcommerceDetailPanel from "./panels/EcommerceDetailPanel";
|
||||
import EcommerceSetPanel from "./panels/EcommerceSetPanel";
|
||||
import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel";
|
||||
@@ -58,16 +59,23 @@ import { ecommerceOssScopes, saveUnifiedEcommerceGenerationRecord, deleteEcommer
|
||||
import { downloadResultAsset } from "../workbench/workbenchDownload";
|
||||
import type { CloneOutputKey, ProductSetOutputKey } from "./utils/platformRules";
|
||||
import {
|
||||
buildHistoryTurnFromRecord,
|
||||
cloneLatestSettingStorageKey,
|
||||
defaultCloneDetailModuleIds,
|
||||
defaultCloneSetCounts,
|
||||
ecommerceHistoryStorageKey,
|
||||
getTurnResults,
|
||||
isCloneImageItem,
|
||||
isCloneResult,
|
||||
isCloneSavedSetting,
|
||||
isEcommerceHistoryRecord,
|
||||
normalizeEcommerceHistoryRecord,
|
||||
normalizeEcommerceHistoryTurn,
|
||||
readCloneLatestSetting,
|
||||
readEcommerceHistoryRecords,
|
||||
removeFilePayloadFromImages,
|
||||
writeCloneLatestSetting,
|
||||
writeEcommerceHistoryRecords,
|
||||
} from "./utils/clonePersistence";
|
||||
import type {
|
||||
CloneImageItem,
|
||||
@@ -78,6 +86,9 @@ import type {
|
||||
CloneSavedSetting,
|
||||
CloneSetCountKey,
|
||||
CloneVideoQualityKey,
|
||||
EcommerceHistoryRecord,
|
||||
EcommerceHistoryStatus,
|
||||
EcommerceHistoryTurn,
|
||||
} from "./utils/clonePersistence";
|
||||
|
||||
const smartCutoutColorPresets = [
|
||||
@@ -296,52 +307,6 @@ interface PreviewTouchGesture {
|
||||
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 {
|
||||
gender?: string;
|
||||
age?: string;
|
||||
@@ -1436,127 +1401,6 @@ function notifyRejectedImages(files: File[]): File[] {
|
||||
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) {
|
||||
return Math.min(cloneVideoDurationMax, Math.max(cloneVideoDurationMin, Math.round(value)));
|
||||
}
|
||||
@@ -7112,184 +6956,23 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
);
|
||||
|
||||
const watermarkPreview = (
|
||||
<main key="watermark" className="ecom-watermark-page ecom-tool-page-enter" aria-label="去水印">
|
||||
<input
|
||||
ref={watermarkInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="ecom-command-hidden-file"
|
||||
onChange={handleWatermarkUpload}
|
||||
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={closeWatermarkRemovalPage}>首页</button>
|
||||
<button type="button" className="ecom-quick-set-back" onClick={closeWatermarkRemovalPage}>上一页</button>
|
||||
</header>
|
||||
<p className="ecom-watermark-intro">上传商品素材,快速清理画面中的水印、文字和瑕疵。</p>
|
||||
<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>
|
||||
<WatermarkToolPage
|
||||
inputRef={watermarkInputRef}
|
||||
urlInputRef={watermarkUrlInputRef}
|
||||
image={watermarkImage}
|
||||
isDragging={isWatermarkDragging}
|
||||
status={watermarkStatus}
|
||||
progress={watermarkProgress}
|
||||
resultUrl={watermarkResultUrl}
|
||||
onUpload={handleWatermarkUpload}
|
||||
onDrop={handleWatermarkDrop}
|
||||
onDraggingChange={setIsWatermarkDragging}
|
||||
onRemoveImage={removeWatermarkImage}
|
||||
onUrlImport={handleWatermarkUrlImport}
|
||||
onGenerate={handleWatermarkGenerate}
|
||||
onDownload={handleWatermarkDownload}
|
||||
onClose={closeWatermarkRemovalPage}
|
||||
/>
|
||||
);
|
||||
|
||||
const translateLanguageOptions = [
|
||||
|
||||
Reference in New Issue
Block a user