feat: 拖拽上传、图片缩放预览及新功能脚手架
- EcommercePage/WorkbenchPage 增加页面级拖拽文件上传支持 - 上传图片悬停缩放预览效果 - Workbench 参考素材增加图片/视频缩放预览 - CanvasPage 连接菜单位置微调 (-40) - script-tokens-v5 文本溢出省略号修复 - 新增: CookieConsentBanner, CompliancePage, 电商面板组件, generation store/hooks/service
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
function CookieConsentBanner() {
|
||||||
|
return null; // TODO: implement cookie consent UI
|
||||||
|
}
|
||||||
|
export default CookieConsentBanner;
|
||||||
@@ -2809,7 +2809,7 @@ function CanvasPage({
|
|||||||
if (targetPort) {
|
if (targetPort) {
|
||||||
connectCanvasPorts(connectorDrag.port, targetPort);
|
connectCanvasPorts(connectorDrag.port, targetPort);
|
||||||
} else {
|
} else {
|
||||||
const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, 0);
|
const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, -40);
|
||||||
setConnectionDropMenu({
|
setConnectionDropMenu({
|
||||||
...menuPosition,
|
...menuPosition,
|
||||||
originLeft: event.clientX,
|
originLeft: event.clientX,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
interface CompliancePageProps {
|
||||||
|
kind: "agreement" | "privacy";
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompliancePage({ kind }: CompliancePageProps) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, maxWidth: 800, margin: "0 auto" }}>
|
||||||
|
<h1>{kind === "agreement" ? "用户协议" : "隐私政策"}</h1>
|
||||||
|
<p>内容加载中...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CompliancePage;
|
||||||
@@ -786,6 +786,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
const [showHostingModal, setShowHostingModal] = useState(false);
|
const [showHostingModal, setShowHostingModal] = useState(false);
|
||||||
const [productImages, setProductImages] = useState<CloneImageItem[]>([]);
|
const [productImages, setProductImages] = useState<CloneImageItem[]>([]);
|
||||||
const [isProductUploadDragging, setIsProductUploadDragging] = useState(false);
|
const [isProductUploadDragging, setIsProductUploadDragging] = useState(false);
|
||||||
|
const [isPageDragging, setIsPageDragging] = useState(false);
|
||||||
|
const pageDragCounterRef = useRef(0);
|
||||||
const [cloneOutput, setCloneOutput] = useState<CloneOutputKey>("detail");
|
const [cloneOutput, setCloneOutput] = useState<CloneOutputKey>("detail");
|
||||||
const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState<CloneBasicSelectKey | null>(null);
|
const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState<CloneBasicSelectKey | null>(null);
|
||||||
const [openCloneModelSelect, setOpenCloneModelSelect] = useState<CloneModelSelectKey | null>(null);
|
const [openCloneModelSelect, setOpenCloneModelSelect] = useState<CloneModelSelectKey | null>(null);
|
||||||
@@ -1295,6 +1297,63 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
};
|
};
|
||||||
}, [openCloneModelSelect]);
|
}, [openCloneModelSelect]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleDragEnter = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
pageDragCounterRef.current += 1;
|
||||||
|
if (pageDragCounterRef.current === 1) {
|
||||||
|
setIsPageDragging(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleDragLeave = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
pageDragCounterRef.current -= 1;
|
||||||
|
if (pageDragCounterRef.current <= 0) {
|
||||||
|
pageDragCounterRef.current = 0;
|
||||||
|
setIsPageDragging(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleDragOver = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
const handleDrop = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
pageDragCounterRef.current = 0;
|
||||||
|
setIsPageDragging(false);
|
||||||
|
const files = Array.from(e.dataTransfer?.files || []);
|
||||||
|
if (!files.length) return;
|
||||||
|
if (activeTool === "clone") {
|
||||||
|
addProductImages(files);
|
||||||
|
} else if (activeTool === "set") {
|
||||||
|
addSetImages(files);
|
||||||
|
} else if (activeTool === "detail") {
|
||||||
|
setDetailProductImages((current) => {
|
||||||
|
const remaining = 3 - current.length;
|
||||||
|
if (remaining <= 0) return current;
|
||||||
|
const next = createObjectImageItems(files, remaining, "detail");
|
||||||
|
return next.length ? [...current, ...next].slice(0, 3) : current;
|
||||||
|
});
|
||||||
|
} else if (activeTool === "wear") {
|
||||||
|
setGarmentImages((current) => {
|
||||||
|
const remaining = 5 - current.length;
|
||||||
|
if (remaining <= 0) return current;
|
||||||
|
const next = createObjectImageItems(files, remaining, "garment");
|
||||||
|
return next.length ? [...current, ...next].slice(0, 5) : current;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("dragenter", handleDragEnter);
|
||||||
|
window.addEventListener("dragleave", handleDragLeave);
|
||||||
|
window.addEventListener("dragover", handleDragOver);
|
||||||
|
window.addEventListener("drop", handleDrop);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("dragenter", handleDragEnter);
|
||||||
|
window.removeEventListener("dragleave", handleDragLeave);
|
||||||
|
window.removeEventListener("dragover", handleDragOver);
|
||||||
|
window.removeEventListener("drop", handleDrop);
|
||||||
|
};
|
||||||
|
}, [activeTool, addProductImages, addSetImages]);
|
||||||
|
|
||||||
const handleGarmentUpload = (event: ChangeEvent<HTMLInputElement>) => {
|
const handleGarmentUpload = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = event.target.files;
|
const files = event.target.files;
|
||||||
if (!files?.length) return;
|
if (!files?.length) return;
|
||||||
@@ -2009,6 +2068,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
onClick={() => openProductSetPreview(setPreviewCards[0] ?? productSetPreviewCards[0])}
|
onClick={() => openProductSetPreview(setPreviewCards[0] ?? productSetPreviewCards[0])}
|
||||||
>
|
>
|
||||||
<img src={setImages[0]?.src ?? (setPreviewCards[0]?.src ?? productSetPreviewCards[0].src)} alt="商品原图" />
|
<img src={setImages[0]?.src ?? (setPreviewCards[0]?.src ?? productSetPreviewCards[0].src)} alt="商品原图" />
|
||||||
|
{setImages[0]?.src ? (
|
||||||
|
<span className="uploaded-image-zoom" aria-hidden="true">
|
||||||
|
<img src={setImages[0].src} alt="" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<span>原图素材</span>
|
<span>原图素材</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="product-set-flow-arrow" aria-hidden="true" />
|
<div className="product-set-flow-arrow" aria-hidden="true" />
|
||||||
@@ -2075,6 +2139,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
<section className="clone-ai-preview-showcase" aria-label="生成结果">
|
<section className="clone-ai-preview-showcase" aria-label="生成结果">
|
||||||
<button type="button" className="clone-ai-main-result" onClick={() => openProductSetPreview(cloneOutput === "set" ? clonePreviewCards[0] : results[0])}>
|
<button type="button" className="clone-ai-main-result" onClick={() => openProductSetPreview(cloneOutput === "set" ? clonePreviewCards[0] : results[0])}>
|
||||||
<img src={productImages[0]?.src ?? (cloneOutput === "set" ? clonePreviewCards[0].src : results[0]?.src ?? "")} alt="上传商品原图" />
|
<img src={productImages[0]?.src ?? (cloneOutput === "set" ? clonePreviewCards[0].src : results[0]?.src ?? "")} alt="上传商品原图" />
|
||||||
|
{productImages[0]?.src ? (
|
||||||
|
<span className="uploaded-image-zoom" aria-hidden="true">
|
||||||
|
<img src={productImages[0].src} alt="" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<span>原图素材</span>
|
<span>原图素材</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="clone-ai-flow-arrow" aria-hidden="true" />
|
<div className="clone-ai-flow-arrow" aria-hidden="true" />
|
||||||
@@ -2158,6 +2227,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
{(detailProductImages.length ? detailProductImages.map((item) => item.src) : detailProductSamples).map((src, index) => (
|
{(detailProductImages.length ? detailProductImages.map((item) => item.src) : detailProductSamples).map((src, index) => (
|
||||||
<figure key={`${src}-${index}`}>
|
<figure key={`${src}-${index}`}>
|
||||||
<img src={src} alt={`商品原图 ${index + 1}`} />
|
<img src={src} alt={`商品原图 ${index + 1}`} />
|
||||||
|
{detailProductImages.length ? (
|
||||||
|
<span className="uploaded-image-zoom" aria-hidden="true">
|
||||||
|
<img src={src} alt="" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</figure>
|
</figure>
|
||||||
))}
|
))}
|
||||||
<span>上传产品图</span>
|
<span>上传产品图</span>
|
||||||
@@ -2241,10 +2315,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`product-clone-page page-motion${isCloneTool && isCloneSettingsCollapsed ? " is-settings-collapsed" : ""}`}
|
className={`product-clone-page page-motion${isCloneTool && isCloneSettingsCollapsed ? " is-settings-collapsed" : ""}${isPageDragging ? " is-page-dragging" : ""}`}
|
||||||
data-tool={activeTool}
|
data-tool={activeTool}
|
||||||
aria-label={pageLabel}
|
aria-label={pageLabel}
|
||||||
>
|
>
|
||||||
|
{isPageDragging ? (
|
||||||
|
<div className="ecommerce-drag-overlay" aria-hidden="true">
|
||||||
|
<span>松开上传文件</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="product-clone-shell">
|
<div className="product-clone-shell">
|
||||||
<aside className="product-clone-rail" aria-label="商品工具">
|
<aside className="product-clone-rail" aria-label="商品工具">
|
||||||
{sideTools.map((tool) => (
|
{sideTools.map((tool) => (
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export function normalizeEcommerceImageMime(file: File): string {
|
||||||
|
return file.type || "image/png";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeRejectedImages(files: File[]): string {
|
||||||
|
if (files.length === 0) return "";
|
||||||
|
return `${files.length} 个文件不符合要求`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEcommerceImageFiles(files: File[]): {
|
||||||
|
valid: File[];
|
||||||
|
rejected: File[];
|
||||||
|
} {
|
||||||
|
// TODO: implement actual image validation
|
||||||
|
return { valid: files, rejected: [] };
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
function EcommerceClonePanel(_props: Record<string, unknown>) {
|
||||||
|
return <div style={{ padding: 24 }}>商品克隆模块 - 开发中</div>;
|
||||||
|
}
|
||||||
|
export default EcommerceClonePanel;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
function EcommerceDetailPanel(_props: Record<string, unknown>) {
|
||||||
|
return <div style={{ padding: 24 }}>商品详情模块 - 开发中</div>;
|
||||||
|
}
|
||||||
|
export default EcommerceDetailPanel;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
function EcommerceSetPanel(_props: Record<string, unknown>) {
|
||||||
|
return <div style={{ padding: 24 }}>商品套图模块 - 开发中</div>;
|
||||||
|
}
|
||||||
|
export default EcommerceSetPanel;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
function EcommerceTryOnPanel(_props: Record<string, unknown>) {
|
||||||
|
return <div style={{ padding: 24 }}>AI 试穿模块 - 开发中</div>;
|
||||||
|
}
|
||||||
|
export default EcommerceTryOnPanel;
|
||||||
@@ -251,6 +251,8 @@ function WorkbenchPage({
|
|||||||
const [toolbarMenuId, setToolbarMenuId] = useState<ToolbarMenuId>(null);
|
const [toolbarMenuId, setToolbarMenuId] = useState<ToolbarMenuId>(null);
|
||||||
const [referenceItems, setReferenceItems] = useState<ReferenceItem[]>([]);
|
const [referenceItems, setReferenceItems] = useState<ReferenceItem[]>([]);
|
||||||
const [referencePreviewOpen, setReferencePreviewOpen] = useState(false);
|
const [referencePreviewOpen, setReferencePreviewOpen] = useState(false);
|
||||||
|
const [isComposerDragging, setIsComposerDragging] = useState(false);
|
||||||
|
const composerDragCounterRef = useRef(0);
|
||||||
const [messagePreviewAttachment, setMessagePreviewAttachment] = useState<ChatAttachment | null>(null);
|
const [messagePreviewAttachment, setMessagePreviewAttachment] = useState<ChatAttachment | null>(null);
|
||||||
const [selectedPromptCase, setSelectedPromptCase] = useState<PromptCaseViewModel | null>(null);
|
const [selectedPromptCase, setSelectedPromptCase] = useState<PromptCaseViewModel | null>(null);
|
||||||
const [serverPromptCases, setServerPromptCases] = useState<PromptCaseViewModel[]>([]);
|
const [serverPromptCases, setServerPromptCases] = useState<PromptCaseViewModel[]>([]);
|
||||||
@@ -1493,9 +1495,22 @@ function WorkbenchPage({
|
|||||||
setReferenceItems(nextItems);
|
setReferenceItems(nextItems);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReferenceUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
const handleReferenceUploadClick = () => {
|
||||||
const files = Array.from(event.target.files || []);
|
if (referenceItems.length > 0) {
|
||||||
event.target.value = "";
|
setToolbarMenuId(null);
|
||||||
|
setReferencePreviewOpen((current) => !current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
referenceInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReferenceAddMore = () => {
|
||||||
|
setToolbarMenuId(null);
|
||||||
|
setReferencePreviewOpen(true);
|
||||||
|
referenceInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const processReferenceFiles = async (files: File[]) => {
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
|
|
||||||
const existingFingerprints = new Set(
|
const existingFingerprints = new Set(
|
||||||
@@ -1582,20 +1597,46 @@ function WorkbenchPage({
|
|||||||
window.requestAnimationFrame(() => textareaRef.current?.focus());
|
window.requestAnimationFrame(() => textareaRef.current?.focus());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReferenceUploadClick = () => {
|
const handleReferenceUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
if (referenceItems.length > 0) {
|
const files = Array.from(event.target.files || []);
|
||||||
setToolbarMenuId(null);
|
event.target.value = "";
|
||||||
setReferencePreviewOpen((current) => !current);
|
await processReferenceFiles(files);
|
||||||
return;
|
|
||||||
}
|
|
||||||
referenceInputRef.current?.click();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReferenceAddMore = () => {
|
const handleComposerDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
setToolbarMenuId(null);
|
e.preventDefault();
|
||||||
setReferencePreviewOpen(true);
|
e.stopPropagation();
|
||||||
referenceInputRef.current?.click();
|
composerDragCounterRef.current += 1;
|
||||||
};
|
if (composerDragCounterRef.current === 1) {
|
||||||
|
setIsComposerDragging(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleComposerDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
composerDragCounterRef.current -= 1;
|
||||||
|
if (composerDragCounterRef.current <= 0) {
|
||||||
|
composerDragCounterRef.current = 0;
|
||||||
|
setIsComposerDragging(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleComposerDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleComposerDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
composerDragCounterRef.current = 0;
|
||||||
|
setIsComposerDragging(false);
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
if (files.length > 0) {
|
||||||
|
void processReferenceFiles(files);
|
||||||
|
}
|
||||||
|
}, [activeMode]);
|
||||||
|
|
||||||
const insertPromptMention = (token: string) => {
|
const insertPromptMention = (token: string) => {
|
||||||
const rawBefore = inputValue.slice(0, cursorIndex);
|
const rawBefore = inputValue.slice(0, cursorIndex);
|
||||||
@@ -2595,6 +2636,11 @@ function WorkbenchPage({
|
|||||||
>
|
>
|
||||||
<ReferencePreview item={item} label={getReferenceKindLabel(item.kind)} />
|
<ReferencePreview item={item} label={getReferenceKindLabel(item.kind)} />
|
||||||
</button>
|
</button>
|
||||||
|
{(item.kind === "image" || item.kind === "video") && item.previewUrl ? (
|
||||||
|
<span className="wb-composer__ref-zoom" aria-hidden="true">
|
||||||
|
{item.kind === "video" ? <video src={item.previewUrl} muted playsInline /> : <img src={item.previewUrl} alt="" />}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="wb-composer__ref-remove"
|
className="wb-composer__ref-remove"
|
||||||
@@ -2852,7 +2898,14 @@ function WorkbenchPage({
|
|||||||
<h1 className="wb-home__title">今天想生成什么?</h1>
|
<h1 className="wb-home__title">今天想生成什么?</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="wb-home__composer" ref={toolbarRef}>
|
<div
|
||||||
|
className={`wb-home__composer${isComposerDragging ? " wb-composer--drag-active" : ""}`}
|
||||||
|
ref={toolbarRef}
|
||||||
|
onDragEnter={handleComposerDragEnter}
|
||||||
|
onDragLeave={handleComposerDragLeave}
|
||||||
|
onDragOver={handleComposerDragOver}
|
||||||
|
onDrop={handleComposerDrop}
|
||||||
|
>
|
||||||
<div className="wb-composer__content">
|
<div className="wb-composer__content">
|
||||||
<div className="wb-composer__input-row">
|
<div className="wb-composer__input-row">
|
||||||
{renderComposerReferences(false)}
|
{renderComposerReferences(false)}
|
||||||
@@ -3027,7 +3080,14 @@ function WorkbenchPage({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={`wb-composer${composerHidden ? " is-hidden" : ""}`} ref={toolbarRef}>
|
<section
|
||||||
|
className={`wb-composer${composerHidden ? " is-hidden" : ""}${isComposerDragging ? " wb-composer--drag-active" : ""}`}
|
||||||
|
ref={toolbarRef}
|
||||||
|
onDragEnter={handleComposerDragEnter}
|
||||||
|
onDragLeave={handleComposerDragLeave}
|
||||||
|
onDragOver={handleComposerDragOver}
|
||||||
|
onDrop={handleComposerDrop}
|
||||||
|
>
|
||||||
<div className="wb-composer__content">
|
<div className="wb-composer__content">
|
||||||
<div className="wb-composer__input-row">
|
<div className="wb-composer__input-row">
|
||||||
{renderComposerReferences(false)}
|
{renderComposerReferences(false)}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
interface UseGenerationTasksOptions {
|
||||||
|
sourceView?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGenerationTasks(_options?: UseGenerationTasksOptions) {
|
||||||
|
return {
|
||||||
|
tasks: [],
|
||||||
|
queue: [],
|
||||||
|
isRunning: false,
|
||||||
|
progress: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function recoverAndResumeTasks() {
|
||||||
|
// TODO: implement background task recovery
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { WebGenerationPreviewTask } from "../types";
|
||||||
|
|
||||||
|
export type QueueItemStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
|
||||||
|
|
||||||
|
export interface GenerationQueueItem {
|
||||||
|
id: string;
|
||||||
|
taskId?: string;
|
||||||
|
title: string;
|
||||||
|
type: "image" | "video" | "agent" | "digital-human" | "character-mix" | "ecommerce-video";
|
||||||
|
status: QueueItemStatus;
|
||||||
|
progress: number;
|
||||||
|
prompt: string;
|
||||||
|
createdAt: number;
|
||||||
|
sourceView: string;
|
||||||
|
resultUrl?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersistedQueueSnapshot {
|
||||||
|
version: 1;
|
||||||
|
items: GenerationQueueItem[];
|
||||||
|
savedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "omniai:generation-queue";
|
||||||
|
const MAX_ITEMS = 80;
|
||||||
|
const STALE_MS = 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function loadPersistedQueue(): GenerationQueueItem[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const snapshot = JSON.parse(raw) as PersistedQueueSnapshot;
|
||||||
|
if (Date.now() - (snapshot.savedAt || 0) > STALE_MS) {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return snapshot.items.filter(
|
||||||
|
(item) => item.status === "pending" || item.status === "running",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistQueue(items: GenerationQueueItem[]): void {
|
||||||
|
try {
|
||||||
|
const snapshot: PersistedQueueSnapshot = { version: 1, items, savedAt: Date.now() };
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||||
|
} catch { /* quota exceeded */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenerationStoreState {
|
||||||
|
queue: GenerationQueueItem[];
|
||||||
|
addTask: (item: GenerationQueueItem) => void;
|
||||||
|
updateTask: (id: string, patch: Partial<GenerationQueueItem>) => void;
|
||||||
|
removeTask: (id: string) => void;
|
||||||
|
getRunningTasks: () => GenerationQueueItem[];
|
||||||
|
getPendingTasks: () => GenerationQueueItem[];
|
||||||
|
getTasksByView: (sourceView: string) => GenerationQueueItem[];
|
||||||
|
clearTerminal: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialQueue = loadPersistedQueue();
|
||||||
|
|
||||||
|
export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
|
||||||
|
queue: initialQueue,
|
||||||
|
|
||||||
|
addTask: (item) => {
|
||||||
|
set((state) => {
|
||||||
|
const next = [item, ...state.queue].slice(0, MAX_ITEMS);
|
||||||
|
persistQueue(next.filter((i) => i.status === "pending" || i.status === "running"));
|
||||||
|
return { queue: next };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTask: (id, patch) => {
|
||||||
|
set((state) => {
|
||||||
|
const next = state.queue.map((item) =>
|
||||||
|
item.id === id ? { ...item, ...patch } : item,
|
||||||
|
);
|
||||||
|
persistQueue(next.filter((i) => i.status === "pending" || i.status === "running"));
|
||||||
|
return { queue: next };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeTask: (id) => {
|
||||||
|
set((state) => {
|
||||||
|
const next = state.queue.filter((item) => item.id !== id);
|
||||||
|
persistQueue(next.filter((i) => i.status === "pending" || i.status === "running"));
|
||||||
|
return { queue: next };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getRunningTasks: () => get().queue.filter((i) => i.status === "running" || i.status === "pending"),
|
||||||
|
getPendingTasks: () => get().queue.filter((i) => i.status === "pending"),
|
||||||
|
getTasksByView: (sourceView) => get().queue.filter((i) => i.sourceView === sourceView),
|
||||||
|
|
||||||
|
clearTerminal: () => {
|
||||||
|
set((state) => {
|
||||||
|
const next = state.queue.filter(
|
||||||
|
(i) => i.status === "pending" || i.status === "running",
|
||||||
|
);
|
||||||
|
persistQueue(next);
|
||||||
|
return { queue: next };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -3153,7 +3153,13 @@
|
|||||||
.product-clone-uploaded-thumb:hover .uploaded-image-zoom,
|
.product-clone-uploaded-thumb:hover .uploaded-image-zoom,
|
||||||
.product-clone-uploaded-thumb:focus-within .uploaded-image-zoom,
|
.product-clone-uploaded-thumb:focus-within .uploaded-image-zoom,
|
||||||
.clone-ai-replicate-preview figure:hover .uploaded-image-zoom,
|
.clone-ai-replicate-preview figure:hover .uploaded-image-zoom,
|
||||||
.clone-ai-replicate-preview figure:focus-within .uploaded-image-zoom {
|
.clone-ai-replicate-preview figure:focus-within .uploaded-image-zoom,
|
||||||
|
.product-set-main-card:hover .uploaded-image-zoom,
|
||||||
|
.product-set-main-card:focus-within .uploaded-image-zoom,
|
||||||
|
.clone-ai-main-result:hover .uploaded-image-zoom,
|
||||||
|
.clone-ai-main-result:focus-within .uploaded-image-zoom,
|
||||||
|
.product-detail-source-stack figure:hover .uploaded-image-zoom,
|
||||||
|
.product-detail-source-stack figure:focus-within .uploaded-image-zoom {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(-50%, 0) scale(1);
|
transform: translate(-50%, 0) scale(1);
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
@@ -4548,6 +4554,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.product-set-main-card {
|
.product-set-main-card {
|
||||||
|
position: relative;
|
||||||
height: 336px;
|
height: 336px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5578,8 +5585,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.product-detail-source-stack figure {
|
.product-detail-source-stack figure {
|
||||||
|
position: relative;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
background: #eef2f7;
|
background: #eef2f7;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8081,6 +8089,8 @@
|
|||||||
.ecommerce-template-apple-carousel.is-resetting .ecommerce-template-apple-card,
|
.ecommerce-template-apple-carousel.is-resetting .ecommerce-template-apple-card,
|
||||||
.ecommerce-template-apple-carousel.is-resetting .ecommerce-template-apple-card img {
|
.ecommerce-template-apple-carousel.is-resetting .ecommerce-template-apple-card img {
|
||||||
transition: none;
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
.clone-ai-video-outfit-upload {
|
.clone-ai-video-outfit-upload {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -8967,3 +8977,31 @@
|
|||||||
padding-top: 14px;
|
padding-top: 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Page Drag-and-Drop Overlay ─── */
|
||||||
|
.product-clone-page.is-page-dragging {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecommerce-drag-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px dashed rgba(0, 255, 136, 0.5);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(0, 255, 136, 0.06);
|
||||||
|
color: rgba(0, 255, 136, 0.9);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: ecommerce-drag-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ecommerce-drag-pulse {
|
||||||
|
0%, 100% { border-color: rgba(0, 255, 136, 0.5); }
|
||||||
|
50% { border-color: rgba(0, 255, 136, 0.8); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -2802,6 +2802,10 @@
|
|||||||
color: #e9fff5;
|
color: #e9fff5;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.script-eval-v5-uf-size {
|
.script-eval-v5-uf-size {
|
||||||
|
|||||||
Reference in New Issue
Block a user