feat: 拖拽上传、图片缩放预览及新功能脚手架

- EcommercePage/WorkbenchPage 增加页面级拖拽文件上传支持
- 上传图片悬停缩放预览效果
- Workbench 参考素材增加图片/视频缩放预览
- CanvasPage 连接菜单位置微调 (-40)
- script-tokens-v5 文本溢出省略号修复
- 新增: CookieConsentBanner, CompliancePage, 电商面板组件, generation store/hooks/service
This commit is contained in:
OmniAI Developer
2026-06-04 17:03:49 +08:00
parent f86ca99548
commit 51762bb2c2
15 changed files with 377 additions and 21 deletions
+1 -1
View File
@@ -2809,7 +2809,7 @@ function CanvasPage({
if (targetPort) {
connectCanvasPorts(connectorDrag.port, targetPort);
} else {
const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, 0);
const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, -40);
setConnectionDropMenu({
...menuPosition,
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;
+80 -1
View File
@@ -786,6 +786,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [showHostingModal, setShowHostingModal] = useState(false);
const [productImages, setProductImages] = useState<CloneImageItem[]>([]);
const [isProductUploadDragging, setIsProductUploadDragging] = useState(false);
const [isPageDragging, setIsPageDragging] = useState(false);
const pageDragCounterRef = useRef(0);
const [cloneOutput, setCloneOutput] = useState<CloneOutputKey>("detail");
const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState<CloneBasicSelectKey | null>(null);
const [openCloneModelSelect, setOpenCloneModelSelect] = useState<CloneModelSelectKey | null>(null);
@@ -1295,6 +1297,63 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
};
}, [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 files = event.target.files;
if (!files?.length) return;
@@ -2009,6 +2068,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
onClick={() => openProductSetPreview(setPreviewCards[0] ?? productSetPreviewCards[0])}
>
<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>
</button>
<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="生成结果">
<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="上传商品原图" />
{productImages[0]?.src ? (
<span className="uploaded-image-zoom" aria-hidden="true">
<img src={productImages[0].src} alt="" />
</span>
) : null}
<span></span>
</button>
<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) => (
<figure key={`${src}-${index}`}>
<img src={src} alt={`商品原图 ${index + 1}`} />
{detailProductImages.length ? (
<span className="uploaded-image-zoom" aria-hidden="true">
<img src={src} alt="" />
</span>
) : null}
</figure>
))}
<span></span>
@@ -2241,10 +2315,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
return (
<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}
aria-label={pageLabel}
>
{isPageDragging ? (
<div className="ecommerce-drag-overlay" aria-hidden="true">
<span></span>
</div>
) : null}
<div className="product-clone-shell">
<aside className="product-clone-rail" aria-label="商品工具">
{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;
+77 -17
View File
@@ -251,6 +251,8 @@ function WorkbenchPage({
const [toolbarMenuId, setToolbarMenuId] = useState<ToolbarMenuId>(null);
const [referenceItems, setReferenceItems] = useState<ReferenceItem[]>([]);
const [referencePreviewOpen, setReferencePreviewOpen] = useState(false);
const [isComposerDragging, setIsComposerDragging] = useState(false);
const composerDragCounterRef = useRef(0);
const [messagePreviewAttachment, setMessagePreviewAttachment] = useState<ChatAttachment | null>(null);
const [selectedPromptCase, setSelectedPromptCase] = useState<PromptCaseViewModel | null>(null);
const [serverPromptCases, setServerPromptCases] = useState<PromptCaseViewModel[]>([]);
@@ -1493,9 +1495,22 @@ function WorkbenchPage({
setReferenceItems(nextItems);
};
const handleReferenceUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
const handleReferenceUploadClick = () => {
if (referenceItems.length > 0) {
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;
const existingFingerprints = new Set(
@@ -1582,20 +1597,46 @@ function WorkbenchPage({
window.requestAnimationFrame(() => textareaRef.current?.focus());
};
const handleReferenceUploadClick = () => {
if (referenceItems.length > 0) {
setToolbarMenuId(null);
setReferencePreviewOpen((current) => !current);
return;
}
referenceInputRef.current?.click();
const handleReferenceUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
await processReferenceFiles(files);
};
const handleReferenceAddMore = () => {
setToolbarMenuId(null);
setReferencePreviewOpen(true);
referenceInputRef.current?.click();
};
const handleComposerDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
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 rawBefore = inputValue.slice(0, cursorIndex);
@@ -2595,6 +2636,11 @@ function WorkbenchPage({
>
<ReferencePreview item={item} label={getReferenceKindLabel(item.kind)} />
</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
type="button"
className="wb-composer__ref-remove"
@@ -2852,7 +2898,14 @@ function WorkbenchPage({
<h1 className="wb-home__title"></h1>
</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__input-row">
{renderComposerReferences(false)}
@@ -3027,7 +3080,14 @@ function WorkbenchPage({
</div>
</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__input-row">
{renderComposerReferences(false)}