merge: 解决合并冲突 - 合并视频流程管道UI、v5样式及新功能模块

This commit is contained in:
OmniAI Developer
2026-06-04 18:19:41 +08:00
79 changed files with 4236 additions and 4367 deletions
+20 -7
View File
@@ -2646,7 +2646,23 @@ function CanvasPage({
}
: null;
})()
: null;
: connectionDropMenu
? (() => {
const source = getNodePortPoint(connectionDropMenu.sourcePort);
const target = getCanvasWorldPointFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
return source
? {
id: "pending-link-preview",
sourceX: source.x,
sourceY: source.y,
targetX: target.x,
targetY: target.y,
sourceSide: connectionDropMenu.sourcePort.side,
targetSide: null,
}
: null;
})()
: null;
const openCanvasAddNodeMenu = useCallback((clientX: number, clientY: number) => {
const menuPosition = positionFloatingMenu(clientX, clientY, 260, 390, 0);
@@ -2816,6 +2832,8 @@ function CanvasPage({
originTop: event.clientY,
sourcePort: connectorDrag.port,
});
setPendingLinkPort(null);
setPendingLinkPreviewPoint(null);
}
} else {
clearPendingConnector();
@@ -2840,7 +2858,7 @@ function CanvasPage({
}, [selectedNode]);
const handleCanvasMouseMove = (event: MouseEvent<HTMLElement>) => {
if (!pendingLinkPort) return;
if (!pendingLinkPort || connectionDropMenu) return;
setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY));
};
@@ -5534,11 +5552,6 @@ function CanvasPage({
role="menu"
onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()}
onMouseMove={(event) => {
if (pendingLinkPort) {
setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY));
}
}}
>
<div className="studio-canvas-add-node-menu__title"></div>
<button
+3 -14
View File
@@ -16,10 +16,10 @@ import WorkspacePageShell from "../../components/WorkspacePageShell";
import OptimizedImage from "../../components/OptimizedImage";
import { EmptyState } from "../../components/EmptyState";
import { cloneWorkflow, createBlankWorkflow } from "../../data/workflows";
import { ossAssets } from "../../data/ossAssets";
import type { WebCanvasWorkflow, WebProjectSummary } from "../../types";
import { getCommunityCaseCover, getWorkflowFromCase, shouldShowInCanvasCommunity } from "./communityCaseUtils";
import { ossThumb } from "../../utils/ossImageOptimize";
const OSS_MUBAN = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban";
interface CommunityPageProps {
projects: WebProjectSummary[];
@@ -31,23 +31,12 @@ interface CommunityPageProps {
onRequireLogin?: (action: string) => boolean | void;
}
const communityCardImages = [
`${OSS_MUBAN}/dianshang1.png`,
`${OSS_MUBAN}/dianshang2.png`,
`${OSS_MUBAN}/dianshang3.png`,
`${OSS_MUBAN}/wechat-7.png`,
`${OSS_MUBAN}/wechat-8.png`,
`${OSS_MUBAN}/wechat-9.png`,
];
const communityCardImages = ossAssets.community.cardImages;
const SLIDE_INTERVAL = 3000;
const CAROUSEL_VISIBLE_COUNT = 3;
const MANUAL_PAUSE_DURATION = 2000;
const COMMUNITY_CAROUSEL_VIDEOS = [
"https://stringtest.oss-cn-hangzhou.aliyuncs.com/test3.mp4",
"https://stringtest.oss-cn-hangzhou.aliyuncs.com/test4.mp4",
"https://stringtest.oss-cn-hangzhou.aliyuncs.com/test6.mp4",
];
const COMMUNITY_CAROUSEL_VIDEOS = ossAssets.community.carouselVideos;
function buildWorkflowFromServerCase(item: ServerCommunityCase, fallback: WebCanvasWorkflow): WebCanvasWorkflow {
const workflow = getWorkflowFromCase(item);
+92 -8
View File
@@ -1,14 +1,98 @@
import { FileTextOutlined, SafetyOutlined } from "@ant-design/icons";
type ComplianceKind = "agreement" | "privacy";
interface CompliancePageProps {
kind: "agreement" | "privacy";
kind: ComplianceKind;
}
function CompliancePage({ kind }: CompliancePageProps) {
const companyName = "OmniAI";
const contactPhone = "15155073618";
const address = "江苏省南京市江北新区扬子江数字视听产业园9栋A楼501";
const agreementSections = [
{
title: "服务范围",
body: "平台提供 AI 图片、视频、脚本、数字人及相关创作辅助服务。具体功能、模型能力、消耗规则以页面展示和平台公告为准。",
},
{
title: "账号与使用",
body: "用户应保证注册信息真实有效,妥善保管账号与登录凭证,不得出租、转让账号或以自动化方式恶意占用平台资源。",
},
{
title: "内容合规",
body: "用户不得上传、生成、发布违法违规、侵权、涉政敏感、暴恐、色情、赌博、诈骗或侵犯他人合法权益的内容。平台有权对违规内容采取删除、限制功能、封禁账号等措施。",
},
{
title: "积分与付费",
body: "积分仅限平台内消费,不支持提现、转让或折现。充值、套餐、赠送积分的有效期、消耗顺序和退费规则以充值页面展示为准。",
},
{
title: "责任限制",
body: "AI 生成结果可能存在偏差,用户应自行审核输出内容并承担使用后果。因不可抗力、第三方服务异常、网络故障造成的服务中断,平台将在合理范围内修复。",
},
];
const privacySections = [
{
title: "收集的信息",
body: "我们会收集账号信息、登录状态、联系方式、创作输入、生成结果、用量记录、设备与网络日志,用于提供服务、安全审计和问题排查。",
},
{
title: "Cookie 与本地存储",
body: "我们使用 Cookie、localStorage 和 sessionStorage 保存登录状态、偏好设置、Cookie 同意状态、创作草稿和断点续传数据。",
},
{
title: "信息使用",
body: "信息用于身份验证、生成任务处理、资产管理、积分计费、客服支持、风控合规、服务优化和法律法规要求的备案审计。",
},
{
title: "第三方处理",
body: "为完成 AI 生成、对象存储、短信邮件、支付或错误监控,我们可能向必要的第三方服务提供最小范围数据,并要求其按约定保护数据安全。",
},
{
title: "用户权利",
body: "你可以通过平台账号功能或联系方式申请访问、更正、删除个人信息,或撤回非必要授权。法律法规另有要求的记录可能需按规定保留。",
},
];
export default function CompliancePage({ kind }: CompliancePageProps) {
const isPrivacy = kind === "privacy";
const sections = isPrivacy ? privacySections : agreementSections;
const title = isPrivacy ? "隐私政策" : "用户协议";
const Icon = isPrivacy ? SafetyOutlined : FileTextOutlined;
return (
<div style={{ padding: 24, maxWidth: 800, margin: "0 auto" }}>
<h1>{kind === "agreement" ? "用户协议" : "隐私政策"}</h1>
<p>...</p>
</div>
<section className="compliance-page">
<div className="compliance-page__inner">
<header className="compliance-hero">
<span className="compliance-hero__icon"><Icon /></span>
<div>
<span className="compliance-hero__eyebrow"></span>
<h1>{title}</h1>
<p>{companyName} 2026 6 3 </p>
</div>
</header>
<div className="compliance-card">
{sections.map((section, index) => (
<article key={section.title} className="compliance-section">
<span>{String(index + 1).padStart(2, "0")}</span>
<div>
<h2>{section.title}</h2>
<p>{section.body}</p>
</div>
</article>
))}
</div>
<footer className="compliance-contact">
<strong></strong>
<span>{address}</span>
<span>{contactPhone}</span>
<span>ICP备2026021747号-1</span>
</footer>
</div>
</section>
);
}
export default CompliancePage;
@@ -114,12 +114,12 @@ function DigitalHumanPage({
keepaliveRestoredRef.current = true;
const saved = loadToolTaskState("digital-human");
if (!saved || saved.resultUrl) return;
setIsProcessing(true);
setIsCreating(true);
cancelRef.current = false;
pollRunRef.current += 1;
setActiveTaskId(saved.taskId);
void waitForTaskResult(saved.taskId).catch(() => {});
setStatus("正在恢复数字人任务...");
setNotice("正在恢复数字人任务...");
}, []);
useEffect(() => {
+202 -185
View File
@@ -13,14 +13,11 @@ import {
SkinOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react";
import { ossAssets } from "../../data/ossAssets";
import { EcommerceProgressBar } from "./EcommerceProgressBar";
const OSS_MUBAN = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban";
const ecommerceGenerated = `${OSS_MUBAN}/ecommerce-carousel-generated.png`;
const ecommerceSlide4 = `${OSS_MUBAN}/slide-4.png`;
const ecommerceSlide5 = `${OSS_MUBAN}/slide-5.png`;
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu";
import EcommerceVideoWorkspace from "./EcommerceVideoWorkspace";
import EcommerceVideoHistoryPanel from "./panels/EcommerceVideoHistoryPanel";
import EcommerceDetailPanel from "./panels/EcommerceDetailPanel";
import EcommerceSetPanel from "./panels/EcommerceSetPanel";
import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel";
@@ -71,6 +68,7 @@ interface CloneResult {
id: string;
src: string;
label: string;
type?: "image" | "video";
}
interface CloneSavedSetting {
@@ -572,7 +570,7 @@ const maxCloneSetTotal = 16;
const maxCloneProductImages = 7;
const maxCloneReferenceImages = 20;
const cloneVideoDurationMin = 5;
const cloneVideoDurationMax = 15;
const cloneVideoDurationMax = 45;
const cloneLatestSettingStorageKey = "omniai.clone-ai.latest-setting";
const cloneVideoQualityOptions: Array<{ key: CloneVideoQualityKey; label: string; desc: string }> = [
{ key: "standard", label: "标准", desc: "快速出片" },
@@ -596,15 +594,12 @@ const tryOnModelOptions = {
ethnicity: ["欧美白人", "亚洲人", "拉美裔", "非洲裔"],
body: ["标准", "高挑", "微胖", "运动"],
};
const sampleResults = [ecommerceSlide4, ecommerceGenerated, ecommerceSlide5];
const productSetAssets = {
main: "https://xiuxiu-pro.meitudata.com/poster/6e3eebacad8d5e47e1896ee7d54827bc.png?imageView2/2/w/800/format/webp/q/80/ignore-error/1",
scene: "https://xiuxiu-pro.meitudata.com/poster/21225fc86b28d9e4d85636483c67408e.png?imageView2/2/w/400/format/webp/q/80/ignore-error/1",
model: "https://xiuxiu-pro.meitudata.com/poster/4b8e6d1bd0996be52822dd1fac73cffd.png?imageView2/2/w/400/format/webp/q/80/ignore-error/1",
detail: "https://xiuxiu-pro.meitudata.com/poster/29dd195a450ee5a7f7451ded6680e969.png?imageView2/2/w/400/format/webp/q/80/ignore-error/1",
selling: "https://xiuxiu-pro.meitudata.com/poster/66bdef541b67588e8db2a03b39dc815b.jpg?imageView2/2/w/400/format/webp/q/80/ignore-error/1",
hosting: "https://xiuxiu-pro-new.meitudata.com/poster/50c17a98c77fac4d0523c8cbdf0d33ca.jpg?imageView2/2/format/webp/q/80/ignore-error/1",
};
const sampleResults = [
ossAssets.ecommerce.slides.slide4,
ossAssets.ecommerce.generated,
ossAssets.ecommerce.slides.slide5,
];
const productSetAssets = ossAssets.ecommerce.productSet;
const productSetPreviewCards = [
{ id: "main", label: "01 主图 (白底/合规)", src: productSetAssets.main },
{ id: "scene", label: "02 场景展示", src: productSetAssets.scene },
@@ -612,21 +607,7 @@ const productSetPreviewCards = [
{ id: "detail", label: "04 细节说明", src: productSetAssets.detail },
{ id: "selling", label: "05 卖点详解", src: productSetAssets.selling },
];
const tryOnAssets = {
dressA: "https://xiuxiu-pro-new.meitudata.com/poster/133ca2d6c13bac6cfaa11fa29a155551.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
dressB: "https://xiuxiu-pro-new.meitudata.com/poster/a661006820e888d9df13023075096e94.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
modelWoman: "https://xiuxiu-pro-new.meitudata.com/poster/f806c6afaf6f38f634c156c5b6058201.png?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
modelMan: "https://xiuxiu-pro-new.meitudata.com/poster/8c26503c67dc695e25e420e48caf4cde.png?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
modelAsian: "https://xiuxiu-pro-new.meitudata.com/poster/0f2a7c92707312ec74647d66f15a6ef9.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
tryA: "https://xiuxiu-pro-new.meitudata.com/poster/7f77e0866f05ff723959e1f48830713c.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
tryB: "https://xiuxiu-pro-new.meitudata.com/poster/0b951004eabcdd7cae595dfdb4c7f8c3.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
jacket: "https://xiuxiu-pro-new.meitudata.com/poster/fdbf10b4c92af5b1986444cdd9affaa5.png?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
jacketResultA: "https://xiuxiu-pro-new.meitudata.com/poster/b1152bb292323b87696dd2f6e518e818.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
jacketResultB: "https://xiuxiu-pro-new.meitudata.com/poster/1c1e757702108fef92d85be0c2802c01.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
hat: "https://xiuxiu-pro-new.meitudata.com/poster/278af735b076ab812888802d3e3db0b8.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
hatResultA: "https://xiuxiu-pro-new.meitudata.com/poster/a3ba241b7aa6060869b096d3f10e5db4.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
hatResultB: "https://xiuxiu-pro-new.meitudata.com/poster/01ed1ae80a187c70c682bb6d0ec6fa68.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
};
const tryOnAssets = ossAssets.ecommerce.tryOn;
const tryOnCards = [
{
@@ -671,18 +652,7 @@ const detailModules = [
const defaultDetailModuleIds: string[] = [];
const defaultCloneDetailModuleIds = ["hero", "selling", "usage", "angle", "scene", "detail"];
const cloneDetailModules = detailModules;
const detailAssets = {
productA: "https://xiuxiu-pro.meitudata.com/poster/182676711565ee98e20cf92d766d1643.png?imageView2/2/format/webp/q/80/ignore-error/1",
productB: "https://xiuxiu-pro.meitudata.com/poster/ba6312cbc3a32ceb8966f9ea20b9ee9c.png?imageView2/2/format/webp/q/80/ignore-error/1",
productC: "https://xiuxiu-pro.meitudata.com/poster/7ee5753a3141fa12cda155126c8225d3.png?imageView2/2/format/webp/q/80/ignore-error/1",
longPage: "https://xiuxiu-pro.meitudata.com/poster/19ef313484fc87c9bdd3cd52ce2a5947.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridA: "https://xiuxiu-pro.meitudata.com/poster/e74e8d920ac0f87020f90457d42a7153.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridB: "https://xiuxiu-pro.meitudata.com/poster/1652064f17c5c2b32ce287244b505c15.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridC: "https://xiuxiu-pro.meitudata.com/poster/dd8abace327edf61d8a8e2d7db42cfbe.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridD: "https://xiuxiu-pro.meitudata.com/poster/7dc397f1cb76a35f7f0ed3c3ce78ba81.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridE: "https://xiuxiu-pro.meitudata.com/poster/1199bd8b968a5162752e1ee2b093d315.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridF: "https://xiuxiu-pro.meitudata.com/poster/7a8cdb3693418df9915741960f8f5aa8.png?imageView2/2/format/webp/q/80/ignore-error/1",
};
const detailAssets = ossAssets.ecommerce.detail;
const detailProductSamples = [detailAssets.productA, detailAssets.productB, detailAssets.productC];
const detailGridSamples = [detailAssets.gridA, detailAssets.gridB, detailAssets.gridC, detailAssets.gridD, detailAssets.gridE, detailAssets.gridF];
@@ -786,9 +756,9 @@ 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 [videoHistoryVisible, setVideoHistoryVisible] = useState(false);
const [videoPlanTrigger, setVideoPlanTrigger] = useState(0);
const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState<CloneBasicSelectKey | null>(null);
const [openCloneModelSelect, setOpenCloneModelSelect] = useState<CloneModelSelectKey | null>(null);
const [cloneModelSelectDropUp, setCloneModelSelectDropUp] = useState(false);
@@ -865,13 +835,13 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const cloneSetTotal = Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0);
const canGenerateSet = setImages.length > 0 && productSetStatus !== "generating";
const canGenerate = (cloneOutput === "video-outfit"
? videoOutfitVideoFile && videoOutfitRefFile
? Boolean(videoOutfitVideoFile && videoOutfitRefFile)
: productImages.length > 0) && status !== "generating";
const canGenerateTryOn = garmentImages.length > 0 && tryOnStatus !== "generating" && tryOnStatus !== "modeling";
const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating";
const cloneVideoDurationProgress =
((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100;
const cloneVideoDurationStyle = {
const cloneVideoDurationStyle: CSSProperties = {
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
} as CSSProperties;
@@ -1297,63 +1267,6 @@ 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;
@@ -1472,7 +1385,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
pRatio: string,
pLanguage: string,
pMarket: string,
setStatusFn: (status: "generating" | "done" | "idle") => void,
setStatusFn: (status: "generating" | "done" | "idle" | "failed") => void,
setResultFn: (urls: string[]) => void,
): Promise<void> => {
setStatusFn("generating");
@@ -1543,13 +1456,13 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
pMarket: string,
tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean },
statusFn?: (status: "generating" | "done" | "idle" | "failed") => void,
resultFn?: (results: CloneImageItem[]) => void,
resultFn?: (results: CloneResult[]) => void,
): Promise<void> => {
setStatusFn("generating");
statusFn?.("generating");
try {
const referenceUrls = await uploadCloneImages(images);
if (!referenceUrls.length) {
setStatusFn("idle");
statusFn?.("idle");
return;
}
@@ -1573,22 +1486,22 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
});
if (resultUrl) {
setResultFn([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]);
setStatusFn("done");
resultFn?.([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]);
statusFn?.("done");
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl });
} else {
setStatusFn("idle");
statusFn?.("idle");
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
}
} catch (err) {
if (err instanceof ServerRequestError && err.status === 402) {
setResultFn([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]);
resultFn?.([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]);
toast.error("余额不足,请充值后继续");
} else {
const msg = err instanceof Error ? err.message : "生成失败";
toast.error(msg);
}
setStatusFn("failed");
statusFn?.("failed");
}
};
@@ -1622,10 +1535,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
});
const { waitForTask } = await import("../../api/taskSubscription");
abortRef.current = { current: false };
const resultUrl = await waitForTask(taskId, { abortRef: abortRef.current });
imageAbortRef.current = { current: false };
const resultUrl = await waitForTask(taskId, { abortRef: imageAbortRef.current });
if (resultUrl) {
setResults([{ id: crypto.randomUUID(), name: "换装视频", src: resultUrl, type: "video", size: 0 }]);
setResults([{ id: crypto.randomUUID(), src: resultUrl, label: "换装视频" }]);
}
setStatus("done");
} catch (err) {
@@ -1661,7 +1574,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
void generateEcommerceImage(
cloneOutput, productImages, requirement,
platform, ratio, language, market,
(s) => setStatus(s as ProductCloneStatus), setResults,
undefined,
(s: string) => setStatus(s as ProductCloneStatus), setResults,
);
lastFailedActionRef.current = () => handleGenerate();
}
@@ -1740,7 +1654,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
void generateEcommerceImage(
"detail", detailProductImages, detailRequirement,
detailPlatform, getPlatformDefaultRatio(detailPlatform), detailLanguage, detailMarket,
(s) => setDetailStatus(s as DetailStatus),
undefined,
(s: string) => setDetailStatus(s as DetailStatus),
(res) => setDetailResultUrl(res[0]?.src ?? null),
);
};
@@ -1964,6 +1879,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
handleGenerate={handleGenerate}
formatRatioDisplayValue={formatRatioDisplayValue}
setVideoOutfitFiles={(video, ref) => { setVideoOutfitVideoFile(video); setVideoOutfitRefFile(ref); }}
onStartVideoPlan={() => setVideoPlanTrigger((n) => n + 1)}
/>
);
@@ -2068,11 +1984,6 @@ 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" />
@@ -2126,61 +2037,170 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<span>
AI <b></b>
</span>
<div className="clone-ai-preview-summary" aria-label="当前生成配置">
<span>{selectedCloneOutput.label}</span>
<span>{platform}</span>
<span>{market}</span>
<span>{language}</span>
<span>{formatRatioDisplayValue(ratio)}</span>
</div>
</header>
{status === "done" ? (
<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" />
<div className="clone-ai-result-grid result-reveal">
{cloneOutput === "set" ? (
clonePreviewCards.map((card) => (
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
<img src={card.src} alt={card.label} />
<span>{card.label}</span>
</button>
))
) : results[0]?.src ? (
<button type="button" onClick={() => openProductSetPreview(results[0])}>
<img src={results[0].src} alt={selectedCloneOutput.label} />
<span>{selectedCloneOutput.label}</span>
</button>
) : null}
</div>
</section>
) : (
<section className="clone-ai-empty-state" aria-live="polite">
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <FrownOutlined /> : <FileImageOutlined />}
<strong>{status === "generating" ? "正在生成" : status === "failed" ? "生成失败" : "等待生成"}</strong>
{status === "generating" ? <EcommerceProgressBar status="generating" label={`${selectedCloneOutput.label}生成`} /> : null}
<span>
{status === "generating"
? `AI 正在为 ${platform} / ${market} 整理${selectedCloneOutput.label}`
: status === "failed"
? "请检查网络后点击下方重试"
: "上传商品原图并填写信息后,AI 将在这里展示生成结果。"}
</span>
{status === "failed" && lastFailedActionRef.current ? (
<button type="button" className="clone-ai-retry-btn" onClick={lastFailedActionRef.current}>
<ReloadOutlined />
</button>
{cloneOutput === "video" ? (
<>
<section className="clone-ai-flow-pipeline" aria-label="生成流程">
{/* Source Node — 原图素材 */}
<div className="clone-ai-flow-source">
<div className="clone-ai-flow-node clone-ai-flow-node--source">
{productImages[0]?.src ? (
<img src={productImages[0].src} alt="商品原图" />
) : (
<div className="clone-ai-flow-node__placeholder">
<FileImageOutlined />
</div>
)}
</div>
<span className="clone-ai-flow-node__label"></span>
</div>
{/* Connector — 分支连接线 */}
<div className="clone-ai-flow-connector" aria-hidden="true">
<div className="clone-ai-flow-connector__trunk" />
<div className="clone-ai-flow-connector__branches">
<div className="clone-ai-flow-connector__branch" />
<div className="clone-ai-flow-connector__branch" />
<div className="clone-ai-flow-connector__branch" />
</div>
</div>
{/* Branches — 生成路径分支 */}
{status === "done" ? (
<div className="clone-ai-flow-branches">
{results[0]?.src ? (
<div className="clone-ai-flow-branch">
<div className="clone-ai-flow-node clone-ai-flow-node--text">
<div className="clone-ai-flow-node__text-content">
<span className="clone-ai-flow-node__text-title">{selectedCloneOutput.label}</span>
<span className="clone-ai-flow-node__text-desc">{requirement || "AI智能生成"}</span>
</div>
</div>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<button
type="button"
className="clone-ai-flow-node clone-ai-flow-node--result"
onClick={() => openProductSetPreview(results[0])}
>
<img src={results[0].src} alt={selectedCloneOutput.label} />
<span className="clone-ai-flow-node__tag">{selectedCloneOutput.label}</span>
</button>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<div className="clone-ai-flow-node clone-ai-flow-node--video">
<img src={results[0].src} alt="分镜视频" />
<span className="clone-ai-flow-node__tag clone-ai-flow-node__tag--accent"></span>
</div>
</div>
) : null}
</div>
) : (
<div className="clone-ai-flow-branches clone-ai-flow-branches--empty">
{[1, 2, 3].map((branchIndex) => (
<div
key={branchIndex}
className={`clone-ai-flow-branch${status === "generating" ? " is-generating" : ""}${status === "failed" ? " is-failed" : ""}`}
>
<div className="clone-ai-flow-node clone-ai-flow-node--text">
<div className="clone-ai-flow-node__text-content">
<span className="clone-ai-flow-node__text-title">{branchIndex}</span>
<span className="clone-ai-flow-node__text-desc">
{status === "generating" ? "AI 解析中..." : "等待生成"}
</span>
</div>
</div>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<div className="clone-ai-flow-node clone-ai-flow-node--result">
<div className="clone-ai-flow-node__placeholder">
{status === "generating" ? <LoadingOutlined /> : <FileImageOutlined />}
</div>
<span className="clone-ai-flow-node__tag">{branchIndex}</span>
</div>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<div className="clone-ai-flow-node clone-ai-flow-node--video">
<div className="clone-ai-flow-node__placeholder">
{status === "generating" ? <LoadingOutlined /> : <FileImageOutlined />}
</div>
<span className="clone-ai-flow-node__tag">{branchIndex}</span>
</div>
</div>
))}
</div>
)}
</section>
{/* Status Overlay — 生成状态覆盖层 */}
{status !== "done" ? (
<section className="clone-ai-flow-status" aria-live="polite">
{status === "generating" ? (
<>
<LoadingOutlined style={{ fontSize: 28 }} />
<strong></strong>
<EcommerceProgressBar status="generating" label={`${selectedCloneOutput.label}生成`} />
<span>AI {platform} / {market} {selectedCloneOutput.label}</span>
</>
) : status === "failed" ? (
<>
<FrownOutlined style={{ fontSize: 28 }} />
<strong></strong>
<span></span>
{lastFailedActionRef.current ? (
<button type="button" className="clone-ai-retry-btn" onClick={lastFailedActionRef.current}>
<ReloadOutlined />
</button>
) : null}
</>
) : (
<span>AI </span>
)}
</section>
) : null}
</section>
</>
) : (
<>
{status === "done" ? (
<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="上传商品原图" />
<span></span>
</button>
<div className="clone-ai-flow-arrow" aria-hidden="true" />
<div className="clone-ai-result-grid result-reveal">
{cloneOutput === "set" ? (
clonePreviewCards.map((card) => (
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
<img src={card.src} alt={card.label} />
<span>{card.label}</span>
</button>
))
) : results[0]?.src ? (
<button type="button" onClick={() => openProductSetPreview(results[0])}>
<img src={results[0].src} alt={selectedCloneOutput.label} />
<span>{selectedCloneOutput.label}</span>
</button>
) : null}
</div>
</section>
) : (
<section className="clone-ai-empty-state" aria-live="polite">
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <FrownOutlined /> : <FileImageOutlined />}
<strong>{status === "generating" ? "正在生成" : status === "failed" ? "生成失败" : "等待生成"}</strong>
{status === "generating" ? <EcommerceProgressBar status="generating" label={`${selectedCloneOutput.label}生成`} /> : null}
<span>
{status === "generating"
? `AI 正在为 ${platform} / ${market} 整理${selectedCloneOutput.label}`
: status === "failed"
? "请检查网络后点击下方重试"
: "上传商品原图并填写信息后,AI 将在这里展示生成结果。"}
</span>
{status === "failed" && lastFailedActionRef.current ? (
<button type="button" className="clone-ai-retry-btn" onClick={lastFailedActionRef.current}>
<ReloadOutlined />
</button>
) : null}
</section>
)}
</>
)}
<section className="clone-ai-bottom-input" aria-label="信息详情">
@@ -2227,11 +2247,6 @@ 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>
@@ -2315,15 +2330,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
return (
<section
className={`product-clone-page page-motion${isCloneTool && isCloneSettingsCollapsed ? " is-settings-collapsed" : ""}${isPageDragging ? " is-page-dragging" : ""}`}
className={`product-clone-page page-motion${isCloneTool && isCloneSettingsCollapsed ? " is-settings-collapsed" : ""}`}
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) => (
@@ -2369,6 +2379,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
durationSeconds={cloneVideoDuration}
resolution={cloneVideoQuality === "standard" ? "720P" : "1080P"}
onRequestLogin={() => ((_props as Record<string, unknown>).isAuthenticated ? undefined : (window.location.hash = "#/login"))}
onOpenHistory={() => setVideoHistoryVisible(true)}
triggerPlan={videoPlanTrigger}
/>
</main>
) : cloneOutput === "video-outfit" && results.length > 0 && results[0].type === "video" ? (
@@ -2437,6 +2449,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</section>
</div>
) : null}
<EcommerceVideoHistoryPanel
visible={videoHistoryVisible}
onClose={() => setVideoHistoryVisible(false)}
/>
</section>
);
}
+163 -129
View File
@@ -1,15 +1,16 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
CloseOutlined,
CopyOutlined,
DownloadOutlined,
FolderAddOutlined,
HistoryOutlined,
LoadingOutlined,
PlayCircleOutlined,
ReloadOutlined,
SendOutlined,
StopOutlined,
} from "@ant-design/icons";
import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks } from "./ecommerceVideoService";
import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks, saveVideoHistory } from "./ecommerceVideoService";
import {
PLAN_STEP_LABELS,
PLAN_STEPS_DISPLAY,
@@ -39,6 +40,8 @@ interface EcommerceVideoWorkspaceProps {
durationSeconds: number;
resolution: string;
onRequestLogin?: () => void;
onOpenHistory?: () => void;
triggerPlan?: number;
}
const ALL_STEPS: PlanStep[] = [
@@ -100,6 +103,8 @@ export default function EcommerceVideoWorkspace({
durationSeconds,
resolution,
onRequestLogin,
onOpenHistory,
triggerPlan,
}: EcommerceVideoWorkspaceProps) {
const [stage, setStage] = useState<EcommerceVideoStage>("idle");
const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null);
@@ -111,6 +116,7 @@ export default function EcommerceVideoWorkspace({
const [failedStep, setFailedStep] = useState<PlanStep | null>(null);
const [error, setError] = useState<string | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null);
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const renderAbortRef = useRef({ current: false });
const setView = useAppStore((s) => s.setView);
@@ -145,26 +151,45 @@ export default function EcommerceVideoWorkspace({
saveEcommerceVideoState({ inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls });
}, [inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]);
// ── Auto-advance: skip manual "next step" clicks ─────────
const autoAdvanceTriggeredRef = useRef(false);
// ── Auto-advance: automatically run the full pipeline ─────────
useEffect(() => {
if (autoAdvanceTriggeredRef.current) return;
const delay = 600;
if (stage === "planned" && planResult && scenes.length > 0) {
autoAdvanceTriggeredRef.current = true;
const timer = setTimeout(() => { void handleGenerateImages(); }, delay);
return () => clearTimeout(timer);
}
if (stage === "imaged" && scenes.every((s) => s.imageUrl)) {
autoAdvanceTriggeredRef.current = true;
const timer = setTimeout(() => { void handleRenderVideos(); }, delay);
return () => clearTimeout(timer);
}
if (stage === "idle" || stage === "cancelled") {
autoAdvanceTriggeredRef.current = false;
}
}, [stage, scenes, planResult]);
// ── External trigger: start plan from parent ────────────────
const triggerPlanPrevRef = useRef(triggerPlan);
useEffect(() => {
if (triggerPlan != null && triggerPlan !== triggerPlanPrevRef.current) {
triggerPlanPrevRef.current = triggerPlan;
void handlePlan();
}
}, [triggerPlan]);
// ── Auto-save: persist completed results to server ──────────
const historySavedRef = useRef(false);
useEffect(() => {
if (stage !== "completed") { historySavedRef.current = false; return; }
if (historySavedRef.current) return;
if (!planResult || !scenes.length) return;
historySavedRef.current = true;
const title = planResult.storyboard?.video_title || planResult.summary?.product_name || "电商广告视频";
saveVideoHistory({
title,
config: { platform, aspectRatio, durationSeconds, resolution },
plan: planResult as unknown as Record<string, unknown>,
scenes: scenes.map((s) => ({ sceneId: s.sceneId, prompt: s.prompt, imageUrl: s.imageUrl, videoUrl: s.resultUrl })),
sourceImageUrls,
}).catch(() => {});
}, [stage, planResult, scenes, sourceImageUrls, platform, aspectRatio, durationSeconds, resolution]);
// ── Keep-alive: resume polling for running tasks ──────────
useEffect(() => {
if (keepalivePollingStartedRef.current) return;
@@ -431,7 +456,7 @@ export default function EcommerceVideoWorkspace({
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
try {
await renderSceneImage(
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio },
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio, productImageUrls: sourceImageUrls },
{
onSceneImageSubmitted: (id, taskId) => {
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s));
@@ -486,7 +511,7 @@ export default function EcommerceVideoWorkspace({
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
try {
await renderScene(
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, aspectRatio, resolution: quality },
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, productImageUrls: sourceImageUrls, aspectRatio, resolution: quality },
{
onSceneSubmitted: (id, taskId) => {
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s));
@@ -529,7 +554,7 @@ export default function EcommerceVideoWorkspace({
setScenes((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
try {
await renderScene(
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl!, aspectRatio, resolution: mapResolutionToQuality(resolution) },
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl!, productImageUrls: sourceImageUrls, aspectRatio, resolution: mapResolutionToQuality(resolution) },
{
onSceneSubmitted: (id, taskId) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)),
onSceneProgress: (id, progress) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, progress } : s)),
@@ -573,6 +598,11 @@ export default function EcommerceVideoWorkspace({
</div>
<div className="ecom-video-flowbar__actions">
{onOpenHistory ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost" onClick={onOpenHistory} title="生成记录">
<HistoryOutlined />
</button>
) : null}
{error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null}
{stage === "idle" && planProgress && (planProgress.summary || planProgress.creatives || planProgress.storyboard) ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
@@ -580,12 +610,6 @@ export default function EcommerceVideoWorkspace({
<ReloadOutlined />
</button>
) : null}
{stage !== "planning" && stage !== "imaging" && stage !== "rendering" ? (
<button type="button" className="ecom-video-flow-action"
onClick={() => void handlePlan()} title={planProgress ? "从头重新策划" : "一键策划"}>
<PlayCircleOutlined />
</button>
) : null}
{stage === "planned" || stage === "imaged" ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
onClick={() => void handleGenerateImages()} title={stage === "imaged" ? "重新生成分镜图" : "生成图片"}>
@@ -619,123 +643,120 @@ export default function EcommerceVideoWorkspace({
<section className="ecom-video-flow-canvas" aria-label="视频分镜流程图">
{!sourceImage ? (
<div className="ecom-video-empty">
<span>"一键策划"</span>
<span></span>
</div>
) : (
<div className="ecom-video-flow-map">
{/* Source image node */}
<article className="ecom-video-flow-node ecom-video-flow-node--source is-ready" aria-label="商品图节点">
<div className="ecom-video-flow-node__media">
<img src={sourceImage} alt="商品图" />
<div className="ecom-video-tree">
{/* Source Node — 附件原图 */}
<div className="ecom-video-tree__source">
<article className="ecom-video-tree-node ecom-video-tree-node--source">
<img src={sourceImage} alt="商品图" />
</article>
<span className="ecom-video-tree-node__label"></span>
</div>
{/* Branch Connector — 分支连接线 */}
<div className="ecom-video-tree__trunk" aria-hidden="true">
<div className="ecom-video-tree__trunk-line" />
<div className="ecom-video-tree__branches-line">
{scenes.length > 0 ? scenes.map((s) => (
<div key={`trunk-${s.sceneId}`} className="ecom-video-tree__branch-tap" />
)) : (
<div className="ecom-video-tree__branch-tap" />
)}
</div>
<span className="ecom-video-flow-node__label"></span>
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
{/* Connector: source → plan text nodes */}
{visiblePlanSteps.length > 0 ? (
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
) : null}
{/* Plan text nodes — side by side */}
{visiblePlanSteps.length > 0 ? (
<div className="ecom-video-scene-strip ecom-video-scene-strip--text" aria-label="策划节点">
{visiblePlanSteps.map((step, idx) => (
<Fragment key={step}>
<article className={`ecom-video-flow-node ecom-video-flow-node--text is-completed${currentStep === step ? " is-pulsing" : ""}`}
aria-label={PLAN_STEP_LABELS[step]} title={PLAN_STEP_LABELS[step]}>
<span className="ecom-video-flow-node__text-icon">
{currentStep === step ? <LoadingOutlined /> : "✓"}
</span>
<span className="ecom-video-flow-node__label">{PLAN_STEP_LABELS[step]}</span>
</div>
{/* Branches — 每个场景一条分支 */}
<div className="ecom-video-tree__rows">
{scenes.length > 0 ? scenes.map((scene, idx) => {
const planDone = completedSteps.length >= ALL_STEPS.length;
const imgReady = !!scene.imageUrl;
const imgRunning = stage === "imaging" && (scene.status === "running" || scene.status === "pending") && !scene.imageUrl;
const vidReady = scene.status === "completed" && scene.resultUrl;
const vidRunning = stage === "rendering" && (scene.status === "running" || scene.status === "pending");
const vidFailed = scene.status === "failed";
return (
<div key={scene.sceneId} className="ecom-video-tree__row" style={{ animationDelay: `${idx * 120}ms` }}>
<article className={`ecom-video-tree-node ecom-video-tree-node--text${planDone ? " is-completed" : currentStep ? " is-active" : ""}`}>
<div className="ecom-video-tree-node__inner">
<span className="ecom-video-tree-node__title">{scene.sceneId}</span>
<span className="ecom-video-tree-node__desc">
{planDone ? "已完成" : stage === "planning" ? "策划中..." : "等待策划"}
</span>
</div>
</article>
{idx < visiblePlanSteps.length - 1 ? (
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
) : null}
</Fragment>
))}
</div>
) : null}
{/* Connector: plan → images */}
{hasImaging ? (
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
) : null}
{/* Storyboard image nodes — side by side per scene */}
{hasImaging ? (
<div className="ecom-video-scene-strip" aria-label="分镜图片节点">
{scenes.map((scene, idx) => {
const imgReady = !!scene.imageUrl;
const imgRunning = stage === "imaging" && (scene.status === "running" || scene.status === "pending") && !scene.imageUrl;
const cls = imgReady ? "is-completed" : imgRunning ? "is-active" : "";
return (
<Fragment key={`img-${scene.sceneId}`}>
<article className={`ecom-video-flow-node ecom-video-flow-node--image ${cls}`}
aria-label={`分镜 ${scene.sceneId}`} title={`分镜 ${scene.sceneId}`}>
<div className="ecom-video-flow-node__media">
{imgReady ? <img src={scene.imageUrl!} alt={`分镜${scene.sceneId}`} />
: imgRunning ? <div className="ecom-video-flow-node__placeholder"><LoadingOutlined /></div>
: <div className="ecom-video-flow-node__placeholder"></div>}
<div className="ecom-video-tree__arrow" aria-hidden="true">
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
<article className={`ecom-video-tree-node ecom-video-tree-node--image${imgReady ? " is-completed" : imgRunning ? " is-active" : ""}`} onClick={imgReady ? () => setPreviewMedia({ url: scene.imageUrl!, type: "image" }) : undefined} style={imgReady ? { cursor: "pointer" } : undefined}>
{imgReady ? (
<img src={scene.imageUrl!} alt={`分镜${scene.sceneId}`} />
) : (
<div className="ecom-video-tree-node__placeholder">
{imgRunning ? <LoadingOutlined /> : <span></span>}
</div>
{imgRunning ? <span className="ecom-video-flow-node__progress">{scene.progress || 0}%</span> : null}
<span className="ecom-video-flow-node__label">{scene.sceneId}</span>
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
{idx < scenes.length - 1 ? (
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
) : null}
</Fragment>
);
})}
</div>
) : null}
{/* Connector: images → videos */}
{hasRendering ? (
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
) : null}
{/* Video nodes — side by side per scene */}
{hasRendering ? (
<div className="ecom-video-scene-strip" aria-label="视频分镜节点">
{scenes.map((scene, idx) => {
const vidReady = scene.status === "completed" && scene.resultUrl;
const vidRunning = stage === "rendering" && (scene.status === "running" || scene.status === "pending");
const vidFailed = scene.status === "failed";
const cls = vidReady ? "is-completed" : vidRunning ? "is-active" : vidFailed ? "is-failed" : "";
return (
<Fragment key={`vid-${scene.sceneId}`}>
<article className={`ecom-video-flow-node ecom-video-flow-node--video ${cls}`}
aria-label={`镜头 ${scene.sceneId}`} title={`镜头 ${scene.sceneId}`}>
<div className="ecom-video-flow-node__media">
{vidReady ? <video src={scene.resultUrl!} muted playsInline loop autoPlay />
: vidRunning ? <div className="ecom-video-flow-node__placeholder"><LoadingOutlined /></div>
: vidFailed ? <div className="ecom-video-flow-node__placeholder"></div>
: <div className="ecom-video-flow-node__placeholder"></div>}
)}
{imgRunning ? <span className="ecom-video-tree-node__progress">{scene.progress || 0}%</span> : null}
<span className="ecom-video-tree-node__tag">{scene.sceneId}</span>
</article>
<div className="ecom-video-tree__arrow" aria-hidden="true">
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
<article className={`ecom-video-tree-node ecom-video-tree-node--video${vidReady ? " is-completed" : vidRunning ? " is-active" : vidFailed ? " is-failed" : ""}`} onClick={vidReady ? () => setPreviewMedia({ url: scene.resultUrl!, type: "video" }) : undefined} style={vidReady ? { cursor: "pointer" } : undefined}>
{vidReady ? (
<video src={scene.resultUrl!} muted playsInline loop autoPlay />
) : (
<div className="ecom-video-tree-node__placeholder">
{vidRunning ? <LoadingOutlined /> : vidFailed ? <span></span> : <span></span>}
</div>
{vidRunning ? <span className="ecom-video-flow-node__progress">{scene.progress || 0}%</span> : null}
<span className="ecom-video-flow-node__label">{scene.sceneId}</span>
{vidFailed ? (
<button type="button" className="ecom-video-flow-node__retry"
onClick={(e) => { e.stopPropagation(); void handleRetryScene(scene); }}
title="重试此镜头">
<ReloadOutlined />
</button>
) : null}
{vidFailed && scene.error ? (
<span className="ecom-video-flow-node__error" title={scene.error}>{scene.error.slice(0, 20)}</span>
) : null}
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
</article>
{idx < scenes.length - 1 ? (
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
)}
{vidRunning ? <span className="ecom-video-tree-node__progress">{scene.progress || 0}%</span> : null}
<span className="ecom-video-tree-node__tag">{scene.sceneId}</span>
{vidFailed ? (
<button type="button" className="ecom-video-tree-node__retry"
onClick={(e) => { e.stopPropagation(); void handleRetryScene(scene); }}
title="重试此镜头">
<ReloadOutlined />
</button>
) : null}
</Fragment>
);
})}
</div>
) : null}
</article>
</div>
);
}) : (
<div className={`ecom-video-tree__row ecom-video-tree__row--empty${stage === "planning" ? " is-planning" : ""}`}>
<article className="ecom-video-tree-node ecom-video-tree-node--text">
<div className="ecom-video-tree-node__inner">
<span className="ecom-video-tree-node__title"></span>
<span className="ecom-video-tree-node__desc">{stage === "planning" ? "策划中..." : "点击一键策划开始"}</span>
</div>
</article>
<div className="ecom-video-tree__arrow" aria-hidden="true">
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
<article className="ecom-video-tree-node ecom-video-tree-node--image">
<div className="ecom-video-tree-node__placeholder">
{stage === "planning" ? <LoadingOutlined /> : <span></span>}
</div>
<span className="ecom-video-tree-node__tag"></span>
</article>
<div className="ecom-video-tree__arrow" aria-hidden="true">
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
<article className="ecom-video-tree-node ecom-video-tree-node--video">
<div className="ecom-video-tree-node__placeholder">
{stage === "planning" ? <LoadingOutlined /> : <span></span>}
</div>
<span className="ecom-video-tree-node__tag"></span>
</article>
</div>
)}
</div>
</div>
)}
@@ -750,6 +771,19 @@ export default function EcommerceVideoWorkspace({
) : null}
{actionNotice ? <div className="ecom-video-flow-notice">{actionNotice}</div> : null}
</section>
{previewMedia ? (
<div className="ecom-video-preview-overlay" onClick={() => setPreviewMedia(null)}>
<button type="button" className="ecom-video-preview-overlay__close" onClick={() => setPreviewMedia(null)}>
<CloseOutlined />
</button>
{previewMedia.type === "image" ? (
<img src={previewMedia.url} alt="预览" onClick={(e) => e.stopPropagation()} />
) : (
<video src={previewMedia.url} controls autoPlay onClick={(e) => e.stopPropagation()} />
)}
</div>
) : null}
</div>
);
}
@@ -1,16 +1,37 @@
export function normalizeEcommerceImageMime(file: File): string {
return file.type || "image/png";
export const ECOMMERCE_SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
export const ECOMMERCE_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
export interface EcommerceImageValidationResult {
accepted: File[];
rejected: Array<{ name: string; reason: string }>;
}
export function summarizeRejectedImages(files: File[]): string {
if (files.length === 0) return "";
return `${files.length} 个文件不符合要求`;
export function validateEcommerceImageFiles(files: File[]): EcommerceImageValidationResult {
const accepted: File[] = [];
const rejected: EcommerceImageValidationResult["rejected"] = [];
files.forEach((file) => {
if (!ECOMMERCE_SUPPORTED_IMAGE_TYPES.has(file.type)) {
rejected.push({ name: file.name, reason: "不支持的图片格式" });
return;
}
if (file.size > ECOMMERCE_MAX_IMAGE_BYTES) {
rejected.push({ name: file.name, reason: "图片超过 10MB" });
return;
}
accepted.push(file);
});
return { accepted, rejected };
}
export function validateEcommerceImageFiles(files: File[]): {
valid: File[];
rejected: File[];
} {
// TODO: implement actual image validation
return { valid: files, rejected: [] };
export function summarizeRejectedImages(rejected: EcommerceImageValidationResult["rejected"]): string {
if (!rejected.length) return "";
const first = rejected[0];
const suffix = rejected.length > 1 ? `${rejected.length} 个文件` : "";
return `${first.name}${suffix} 已跳过:${first.reason}`;
}
export function normalizeEcommerceImageMime(type: string): string {
return ECOMMERCE_SUPPORTED_IMAGE_TYPES.has(type) ? type : "image/png";
}
+26 -16
View File
@@ -1,18 +1,28 @@
import ecommerceCarouselGenerated from "../../assets/ecommerce-carousel-generated.png";
import moreTemplateSlide1 from "../../assets/more-template-carousel/slide-1.jpg";
import moreTemplateSlide2 from "../../assets/more-template-carousel/slide-2.jpg";
import moreTemplateSlide3 from "../../assets/more-template-carousel/slide-3.jpg";
import moreTemplateSlide4 from "../../assets/more-template-carousel/slide-4.png";
import moreTemplateSlide5 from "../../assets/more-template-carousel/slide-5.gif";
import ecommerceHeroSlide1 from "../../assets/ecommerce-hero-carousel/slide-1.webp";
import ecommerceHeroSlide2 from "../../assets/ecommerce-hero-carousel/slide-2.webp";
import ecommerceHeroSlide3 from "../../assets/ecommerce-hero-carousel/slide-3.webp";
import ecommerceHeroSlide4 from "../../assets/ecommerce-hero-carousel/slide-4.webp";
import ecommerceHeroSlide5 from "../../assets/ecommerce-hero-carousel/slide-5.webp";
import ecommerceCarouselImage1 from "../../../tu/微信图片_20260514125332_8_2.png";
import ecommerceCarouselImage2 from "../../../tu/微信图片_20260514125332_9_2.png";
import ecommerceCarouselImage3 from "../../../tu/微信图片_20260514125332_7_2.png";
import ecommerceCarouselImage4 from "../../../tu/微信图片_20260514125332_12_2.png";
import { ossAssets } from "../../data/ossAssets";
const [
moreTemplateSlide1,
moreTemplateSlide2,
moreTemplateSlide3,
moreTemplateSlide4,
moreTemplateSlide5,
] = ossAssets.ecommerce.templateSlides;
const [
ecommerceHeroSlide1,
ecommerceHeroSlide2,
ecommerceHeroSlide3,
ecommerceHeroSlide4,
ecommerceHeroSlide5,
] = ossAssets.ecommerce.heroSlides;
const [
ecommerceCarouselImage1,
ecommerceCarouselImage2,
ecommerceCarouselImage3,
ecommerceCarouselImage4,
ecommerceCarouselImage5,
ecommerceCarouselImage6,
] = ossAssets.ecommerce.templateCases;
const ecommerceCarouselGenerated = ossAssets.ecommerce.generated;
export interface TemplateCase {
title: string;
@@ -124,6 +134,6 @@ export const templateCases: TemplateCase[] = [
title: "促销卖点组合图",
category: "详情图",
summary: "把成分、规格、卖点拆成清晰的详情页模块。",
imageUrl: "https://picsum.photos/id/1080/900/620",
imageUrl: ecommerceCarouselImage6,
},
];
@@ -152,6 +152,7 @@ export interface RenderSceneImageInput {
sceneId: number;
prompt: string;
aspectRatio: string;
productImageUrls: string[];
}
export interface RenderImageCallbacks {
@@ -171,6 +172,7 @@ export async function renderSceneImage(
prompt: input.prompt,
ratio: input.aspectRatio,
quality: "2K",
referenceUrls: input.productImageUrls,
});
callbacks.onSceneImageSubmitted(input.sceneId, taskId);
@@ -192,6 +194,7 @@ export interface RenderSceneInput {
prompt: string;
durationSeconds: number;
imageUrl: string;
productImageUrls: string[];
aspectRatio: string;
resolution: string;
model?: string;
@@ -209,9 +212,10 @@ export async function renderScene(
callbacks: RenderCallbacks,
abortRef: { current: boolean },
): Promise<void> {
const allReferenceUrls = [...input.productImageUrls, input.imageUrl];
const model = resolveVideoRequestModel({
model: input.model || "happyhorse-1.0",
referenceUrls: [input.imageUrl],
referenceUrls: allReferenceUrls,
});
const { taskId } = await aiGenerationClient.createVideoTask({
@@ -222,7 +226,7 @@ export async function renderScene(
quality: input.resolution,
resolution: input.resolution,
frameMode: "start-end",
referenceUrls: [input.imageUrl],
referenceUrls: allReferenceUrls,
hasReferenceVideo: false,
});
@@ -254,3 +258,73 @@ export function buildSceneTasks(
};
});
}
// ── Video History API ──────────────────────────────────
export interface VideoHistoryScene {
sceneId: number;
prompt: string;
imageUrl?: string | null;
videoUrl?: string | null;
}
export interface VideoHistoryItem {
id: number;
title: string;
config: Record<string, unknown>;
scenes: VideoHistoryScene[];
sourceImageUrls: string[];
createdAt: string;
}
export interface VideoHistoryListResponse {
items: VideoHistoryItem[];
total: number;
limit: number;
offset: number;
}
import { getStoredToken } from "../../api/serverConnection";
const API_BASE = "/api/ai/ecommerce/video-history";
function getAuthHeaders(): Record<string, string> {
const token = getStoredToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
export async function saveVideoHistory(payload: {
title: string;
config: Record<string, unknown>;
plan: Record<string, unknown>;
scenes: VideoHistoryScene[];
sourceImageUrls: string[];
}): Promise<{ id: number; createdAt: string }> {
const res = await fetch(API_BASE, {
method: "POST",
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error("保存历史记录失败");
return res.json();
}
export async function fetchVideoHistory(
limit = 20,
offset = 0,
): Promise<VideoHistoryListResponse> {
const res = await fetch(
`${API_BASE}?limit=${limit}&offset=${offset}`,
{ headers: getAuthHeaders() },
);
if (!res.ok) throw new Error("获取历史记录失败");
return res.json();
}
export async function deleteVideoHistory(id: number): Promise<void> {
const res = await fetch(`${API_BASE}/${id}`, {
method: "DELETE",
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error("删除失败");
}
@@ -1,4 +1,752 @@
function EcommerceClonePanel(_props: Record<string, unknown>) {
return <div style={{ padding: 24 }}> - </div>;
import {
CloudUploadOutlined,
CloseOutlined,
FileImageOutlined,
LoadingOutlined,
QuestionCircleOutlined,
ReloadOutlined,
SettingOutlined,
} from "@ant-design/icons";
import type { CSSProperties, ChangeEvent, DragEvent, MutableRefObject, RefObject } from "react";
import { useRef, useState } from "react";
type ProductSetOutputKey = "set" | "detail" | "model" | "video";
type CloneOutputKey = ProductSetOutputKey | "hot" | "video-outfit";
type CloneSetCountKey = "selling" | "white" | "scene";
type CloneModelPanelTab = "scene" | "model";
type CloneReferenceMode = "upload" | "link";
type CloneReplicateLevelKey = "style" | "high";
type CloneVideoQualityKey = "standard" | "high" | "ultra";
type CloneBasicSelectKey = "platform" | "market" | "language" | "ratio";
type CloneModelSelectKey = "gender" | "age" | "ethnicity" | "body";
interface CloneImageItem {
id: string;
src: string;
name: string;
}
interface CloneBasicSelectItem {
key: CloneBasicSelectKey;
label: string;
value: string;
options: string[];
onChange: (value: string) => void;
}
interface CloneModelSelectItem {
key: CloneModelSelectKey;
label: string;
value: string;
options: string[];
onChange: (value: string) => void;
}
interface CloneSetCountOption {
key: CloneSetCountKey;
title: string;
desc: string;
}
interface CloneOutputOption {
key: CloneOutputKey;
label: string;
}
interface CloneReplicateLevelOption {
key: CloneReplicateLevelKey;
title: string;
desc: string;
}
interface CloneVideoQualityOption {
key: CloneVideoQualityKey;
label: string;
desc: string;
}
interface CloneDetailModule {
id: string;
title: string;
desc: string;
}
interface EcommerceClonePanelProps {
productInputRef: RefObject<HTMLInputElement>;
cloneReferenceInputRef: RefObject<HTMLInputElement>;
productImages: CloneImageItem[];
isProductUploadDragging: boolean;
cloneOutput: CloneOutputKey;
cloneOutputOptions: CloneOutputOption[];
cloneBasicSelects: CloneBasicSelectItem[];
openCloneBasicSelect: CloneBasicSelectKey | null;
cloneReferenceMode: CloneReferenceMode;
cloneReferenceImages: CloneImageItem[];
maxCloneReferenceImages: number;
cloneReplicateLevel: CloneReplicateLevelKey;
cloneReplicateLevelOptions: CloneReplicateLevelOption[];
cloneSetCounts: Record<CloneSetCountKey, number>;
cloneSetCountOptions: CloneSetCountOption[];
cloneSetTotal: number;
minCloneSetTotal: number;
maxCloneSetTotal: number;
selectedCloneDetailModules: string[];
cloneDetailModules: CloneDetailModule[];
cloneModelPanelTab: CloneModelPanelTab;
tryOnScenes: string[];
selectedCloneModelScenes: string[];
cloneModelCustomScene: string;
cloneModelSelects: CloneModelSelectItem[];
openCloneModelSelect: CloneModelSelectKey | null;
cloneModelSelectDropUp: boolean;
cloneModelAppearance: string;
cloneVideoQuality: CloneVideoQualityKey;
cloneVideoQualityOptions: CloneVideoQualityOption[];
cloneVideoDuration: number;
cloneVideoDurationMin: number;
cloneVideoDurationMax: number;
cloneVideoDurationStyle: CSSProperties;
cloneVideoSmart: boolean;
canGenerate: boolean;
status: string;
lastFailedActionRef: MutableRefObject<(() => void) | null>;
setIsProductUploadDragging: (value: boolean) => void;
handleProductDrop: (event: DragEvent<HTMLDivElement>) => void;
removeProductImage: (id: string) => void;
handleProductUpload: (event: ChangeEvent<HTMLInputElement>) => void;
handleCloneOutputChange: (value: CloneOutputKey) => void;
setOpenCloneBasicSelect: (value: CloneBasicSelectKey | null) => void;
setCloneReferenceMode: (value: CloneReferenceMode) => void;
handleCloneReferenceUpload: (event: ChangeEvent<HTMLInputElement>) => void;
setCloneReplicateLevel: (value: CloneReplicateLevelKey) => void;
startCloneSetCountHold: (key: CloneSetCountKey, delta: -1 | 1, disabled: boolean) => void;
clearCloneSetCountHold: () => void;
toggleCloneDetailModule: (id: string) => void;
setCloneModelPanelTab: (value: CloneModelPanelTab) => void;
toggleCloneModelScene: (scene: string) => void;
setCloneModelCustomScene: (value: string) => void;
setOpenCloneModelSelect: (value: CloneModelSelectKey | null) => void;
setCloneModelSelectDropUp: (value: boolean) => void;
setCloneModelAppearance: (value: string) => void;
setCloneVideoQuality: (value: CloneVideoQualityKey) => void;
setCloneVideoDuration: (value: number) => void;
clampCloneVideoDuration: (value: number) => number;
setCloneVideoSmart: (updater: (current: boolean) => boolean) => void;
handleGenerate: () => void;
formatRatioDisplayValue: (value: string) => string;
setVideoOutfitFiles?: (video: File | null, ref: File | null) => void;
onStartVideoPlan?: () => void;
}
export default function EcommerceClonePanel({
productInputRef,
cloneReferenceInputRef,
productImages,
isProductUploadDragging,
cloneOutput,
cloneOutputOptions,
cloneBasicSelects,
openCloneBasicSelect,
cloneReferenceMode,
cloneReferenceImages,
maxCloneReferenceImages,
cloneReplicateLevel,
cloneReplicateLevelOptions,
cloneSetCounts,
cloneSetCountOptions,
cloneSetTotal,
minCloneSetTotal,
maxCloneSetTotal,
selectedCloneDetailModules,
cloneDetailModules,
cloneModelPanelTab,
tryOnScenes,
selectedCloneModelScenes,
cloneModelCustomScene,
cloneModelSelects,
openCloneModelSelect,
cloneModelSelectDropUp,
cloneModelAppearance,
cloneVideoQuality,
cloneVideoQualityOptions,
cloneVideoDuration,
cloneVideoDurationMin,
cloneVideoDurationMax,
cloneVideoDurationStyle,
cloneVideoSmart,
canGenerate,
status,
lastFailedActionRef,
setIsProductUploadDragging,
handleProductDrop,
removeProductImage,
handleProductUpload,
handleCloneOutputChange,
setOpenCloneBasicSelect,
setCloneReferenceMode,
handleCloneReferenceUpload,
setCloneReplicateLevel,
startCloneSetCountHold,
clearCloneSetCountHold,
toggleCloneDetailModule,
setCloneModelPanelTab,
toggleCloneModelScene,
setCloneModelCustomScene,
setOpenCloneModelSelect,
setCloneModelSelectDropUp,
setCloneModelAppearance,
setCloneVideoQuality,
setCloneVideoDuration,
clampCloneVideoDuration,
setCloneVideoSmart,
handleGenerate,
formatRatioDisplayValue,
setVideoOutfitFiles,
onStartVideoPlan,
}: EcommerceClonePanelProps) {
const videoOutfitVideoRef = useRef<HTMLInputElement>(null);
const videoOutfitRefRef = useRef<HTMLInputElement>(null);
const [videoOutfitVideoUrl, setVideoOutfitVideoUrl] = useState<string | null>(null);
const [videoOutfitRefUrl, setVideoOutfitRefUrl] = useState<string | null>(null);
const handleVideoOutfitVideoChange = () => {
const file = videoOutfitVideoRef.current?.files?.[0] || null;
if (file) setVideoOutfitVideoUrl(URL.createObjectURL(file));
setVideoOutfitFiles?.(file, videoOutfitRefRef.current?.files?.[0] || null);
};
const handleVideoOutfitRefChange = () => {
const file = videoOutfitRefRef.current?.files?.[0] || null;
if (file) setVideoOutfitRefUrl(URL.createObjectURL(file));
setVideoOutfitFiles?.(videoOutfitVideoRef.current?.files?.[0] || null, file);
};
return (
<>
<div className="product-clone-panel__scroll clone-ai-panel">
<header className="clone-ai-logo">
<span className="clone-ai-logo__mark">AI</span>
<strong></strong>
</header>
<section className="clone-ai-card">
<h2>
<CloudUploadOutlined />
</h2>
<div
role="button"
tabIndex={0}
className={`clone-ai-upload-zone${isProductUploadDragging ? " is-dragging" : ""}`}
onClick={() => productInputRef.current?.click()}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
productInputRef.current?.click();
}
}}
onDragEnter={(event) => {
event.preventDefault();
setIsProductUploadDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsProductUploadDragging(false)}
onDrop={handleProductDrop}
>
<div className="clone-ai-upload-main">
<span className="clone-ai-upload-icon">
<FileImageOutlined />
</span>
<span className="clone-ai-upload-title"></span>
<strong>
<span aria-hidden="true">+</span>
</strong>
<span className="clone-ai-upload-hint"> 7 </span>
</div>
{productImages.length ? (
<div className="clone-ai-uploaded-files" aria-label="已上传商品原图">
{productImages.map((item) => (
<figure key={item.id} className="clone-ai-uploaded-file">
<img src={item.src} alt={item.name} />
<span className="uploaded-image-zoom" aria-hidden="true">
<img src={item.src} alt="" />
</span>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
removeProductImage(item.id);
}}
aria-label={`删除${item.name}`}
>
<CloseOutlined />
</button>
</figure>
))}
</div>
) : null}
</div>
<input ref={productInputRef} type="file" accept="image/*" multiple onChange={handleProductUpload} />
</section>
<section className="clone-ai-card">
<h2>
<SettingOutlined />
</h2>
<div className="clone-ai-settings-section">
<span className="clone-ai-settings-label"></span>
<div className="clone-ai-tag-group" role="radiogroup" aria-label="生成内容">
{cloneOutputOptions.map((option) => (
<button
key={option.key}
type="button"
className={cloneOutput === option.key ? "is-active" : ""}
aria-pressed={cloneOutput === option.key}
onClick={() => handleCloneOutputChange(option.key)}
>
{option.label}
</button>
))}
</div>
</div>
<div className="clone-ai-settings-section">
<span className="clone-ai-settings-label"></span>
<div className="clone-ai-select-group">
{cloneBasicSelects.map((item) => {
const hasMultipleOptions = item.options.length > 1;
const isOpen = hasMultipleOptions && openCloneBasicSelect === item.key;
return (
<div key={item.key} className="clone-ai-basic-select" data-clone-basic-select>
<button
type="button"
className={`${isOpen ? "is-open" : ""}${hasMultipleOptions ? "" : " is-static"}`}
aria-expanded={hasMultipleOptions ? isOpen : undefined}
aria-haspopup={hasMultipleOptions ? "listbox" : undefined}
aria-controls={hasMultipleOptions ? `clone-basic-select-${item.key}` : undefined}
onClick={() => setOpenCloneBasicSelect(hasMultipleOptions ? (isOpen ? null : item.key) : null)}
>
<span>{item.label}</span>
<strong>{item.key === "ratio" ? formatRatioDisplayValue(item.value) : item.value}</strong>
{hasMultipleOptions ? <i aria-hidden="true" /> : null}
</button>
{hasMultipleOptions && isOpen ? (
<div id={`clone-basic-select-${item.key}`} className="clone-ai-basic-select__menu" role="listbox">
{item.options.map((option) => (
<button
key={option}
type="button"
className={item.value === option ? "is-active" : ""}
role="option"
aria-selected={item.value === option}
onClick={() => {
item.onChange(option);
setOpenCloneBasicSelect(null);
}}
>
{item.key === "ratio" ? formatRatioDisplayValue(option) : option}
</button>
))}
</div>
) : null}
</div>
);
})}
</div>
</div>
</section>
{cloneOutput === "hot" ? (
<section className="clone-ai-replicate-panel" aria-label="爆款图复刻设置">
<div className="clone-ai-replicate-section">
<span className="clone-ai-replicate-title"></span>
<div className="clone-ai-replicate-tabs" role="tablist" aria-label="参考内容来源">
<button
type="button"
className={cloneReferenceMode === "upload" ? "is-active" : ""}
aria-selected={cloneReferenceMode === "upload"}
onClick={() => setCloneReferenceMode("upload")}
>
</button>
<button
type="button"
className={cloneReferenceMode === "link" ? "is-active" : ""}
aria-selected={cloneReferenceMode === "link"}
onClick={() => setCloneReferenceMode("link")}
>
</button>
</div>
{cloneReferenceMode === "upload" ? (
<button type="button" className="clone-ai-replicate-upload" onClick={() => cloneReferenceInputRef.current?.click()}>
<span>
<CloudUploadOutlined />
<span className="clone-ai-replicate-upload-text"></span>
</span>
<em>{cloneReferenceImages.length ? `已选 ${cloneReferenceImages.length}/${maxCloneReferenceImages}` : `最多 ${maxCloneReferenceImages}`}</em>
{cloneReferenceImages.length ? (
<div className="clone-ai-replicate-preview" aria-hidden="true">
{cloneReferenceImages.slice(0, 4).map((item) => (
<figure key={item.id}>
<img src={item.src} alt="" />
<span className="uploaded-image-zoom">
<img src={item.src} alt="" />
</span>
</figure>
))}
{cloneReferenceImages.length > 4 ? <b>+{cloneReferenceImages.length - 4}</b> : null}
</div>
) : null}
</button>
) : (
<label className="clone-ai-replicate-link">
<input placeholder="粘贴商品图或详情页链接" />
</label>
)}
<input
ref={cloneReferenceInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
onChange={handleCloneReferenceUpload}
/>
</div>
<div className="clone-ai-replicate-section">
<span className="clone-ai-replicate-title"></span>
<div className="clone-ai-replicate-levels" role="radiogroup" aria-label="复刻程度">
{cloneReplicateLevelOptions.map((option) => (
<button
key={option.key}
type="button"
className={cloneReplicateLevel === option.key ? "is-active" : ""}
aria-pressed={cloneReplicateLevel === option.key}
onClick={() => setCloneReplicateLevel(option.key)}
>
<strong>{option.title}</strong>
<span>{option.desc}</span>
</button>
))}
</div>
</div>
</section>
) : null}
{cloneOutput === "set" ? (
<section className="clone-ai-count-panel" aria-label="套图图片数量">
<p> 1-16 </p>
<div className="clone-ai-count-list">
{cloneSetCountOptions.map((item) => {
const count = cloneSetCounts[item.key];
const decrementDisabled = count <= 0 || cloneSetTotal <= minCloneSetTotal;
const incrementDisabled = cloneSetTotal >= maxCloneSetTotal;
return (
<div key={item.key} className="clone-ai-count-row">
<div className="clone-ai-count-copy">
<strong>{item.title}</strong>
<span>{item.desc}</span>
</div>
<div className="clone-ai-count-stepper" aria-label={`${item.title}数量`}>
<button
type="button"
disabled={decrementDisabled}
onPointerDown={(event) => {
event.preventDefault();
startCloneSetCountHold(item.key, -1, decrementDisabled);
}}
onPointerUp={clearCloneSetCountHold}
onPointerLeave={clearCloneSetCountHold}
onPointerCancel={clearCloneSetCountHold}
onBlur={clearCloneSetCountHold}
aria-label={`减少${item.title}`}
>
-
</button>
<b>{count}</b>
<button
type="button"
disabled={incrementDisabled}
onPointerDown={(event) => {
event.preventDefault();
startCloneSetCountHold(item.key, 1, incrementDisabled);
}}
onPointerUp={clearCloneSetCountHold}
onPointerLeave={clearCloneSetCountHold}
onPointerCancel={clearCloneSetCountHold}
onBlur={clearCloneSetCountHold}
aria-label={`增加${item.title}`}
>
+
</button>
</div>
</div>
);
})}
</div>
</section>
) : null}
{cloneOutput === "detail" ? (
<section className="clone-ai-module-panel" aria-label="详情图包含模块">
<p>
<QuestionCircleOutlined />
</p>
<div className="clone-ai-module-list">
{cloneDetailModules.map((module) => {
const isSelected = selectedCloneDetailModules.includes(module.id);
return (
<button
key={module.id}
type="button"
className={isSelected ? "is-active" : ""}
aria-pressed={isSelected}
onClick={() => toggleCloneDetailModule(module.id)}
>
<strong>{module.title}</strong>
<span>{module.desc}</span>
</button>
);
})}
</div>
</section>
) : null}
{cloneOutput === "model" ? (
<section className="clone-ai-model-panel" aria-label="模特图设置">
<div className="clone-ai-model-tabs" role="tablist" aria-label="模特图设置类型">
<button
type="button"
className={cloneModelPanelTab === "scene" ? "is-active" : ""}
aria-selected={cloneModelPanelTab === "scene"}
onClick={() => setCloneModelPanelTab("scene")}
>
</button>
<button
type="button"
className={cloneModelPanelTab === "model" ? "is-active" : ""}
aria-selected={cloneModelPanelTab === "model"}
onClick={() => setCloneModelPanelTab("model")}
>
</button>
</div>
<div className="clone-ai-model-scroll">
{cloneModelPanelTab === "scene" ? (
<div className="clone-ai-model-scenes">
<div className="clone-ai-model-scene-grid">
{tryOnScenes.map((scene) => {
const isSelected = selectedCloneModelScenes.includes(scene);
return (
<button
key={scene}
type="button"
className={isSelected ? "is-active" : ""}
aria-pressed={isSelected}
onClick={() => toggleCloneModelScene(scene)}
>
<span aria-hidden="true" />
{scene}
</button>
);
})}
</div>
<label className="clone-ai-model-textarea">
<strong></strong>
<textarea
value={cloneModelCustomScene}
onChange={(event) => setCloneModelCustomScene(event.target.value)}
placeholder="描述你想要的场景:如秋季枫叶小径、暖色调午后阳光、模特倚靠树干..."
/>
</label>
</div>
) : (
<div className="clone-ai-model-profile">
<div className="clone-ai-model-select-grid">
{cloneModelSelects.map((item) => {
const isOpen = openCloneModelSelect === item.key;
return (
<div
key={item.key}
className={`clone-ai-model-select${isOpen ? " is-open" : ""}${
isOpen && cloneModelSelectDropUp ? " is-drop-up" : ""
}`}
data-clone-model-select
>
<span>{item.label}</span>
<button
type="button"
className={isOpen ? "is-open" : ""}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={`clone-model-select-${item.key}`}
onClick={(event) => {
setOpenCloneBasicSelect(null);
if (!isOpen) {
event.currentTarget.scrollIntoView({ block: "center", inline: "nearest" });
const triggerRect = event.currentTarget.getBoundingClientRect();
const scrollRect = event.currentTarget.closest(".clone-ai-model-scroll")?.getBoundingClientRect();
const lowerBoundary = Math.min(window.innerHeight, scrollRect?.bottom ?? window.innerHeight);
const upperBoundary = Math.max(0, scrollRect?.top ?? 0);
const estimatedMenuHeight = Math.min(150, item.options.length * 36 + 12);
const belowSpace = lowerBoundary - triggerRect.bottom;
const aboveSpace = triggerRect.top - upperBoundary;
setCloneModelSelectDropUp(belowSpace < estimatedMenuHeight && aboveSpace > belowSpace);
} else {
setCloneModelSelectDropUp(false);
}
setOpenCloneModelSelect(isOpen ? null : item.key);
}}
>
<strong>{item.value}</strong>
<i aria-hidden="true" />
</button>
{isOpen ? (
<div id={`clone-model-select-${item.key}`} className="clone-ai-model-select__menu" role="listbox">
{item.options.map((option) => (
<button
key={option}
type="button"
className={item.value === option ? "is-active" : ""}
role="option"
aria-selected={item.value === option}
onClick={() => {
item.onChange(option);
setOpenCloneModelSelect(null);
setCloneModelSelectDropUp(false);
}}
>
{option}
</button>
))}
</div>
) : null}
</div>
);
})}
</div>
<label className="clone-ai-model-textarea">
<strong></strong>
<textarea
value={cloneModelAppearance}
onChange={(event) => setCloneModelAppearance(event.target.value)}
placeholder="例如:小麦色皮肤、齐刘海、眼角有泪痣..."
/>
</label>
</div>
)}
</div>
</section>
) : null}
{cloneOutput === "video" ? (
<section className="clone-ai-video-panel" aria-label="短视频设置">
<div className="clone-ai-video-section">
<span className="clone-ai-video-title"></span>
<div className="clone-ai-video-options">
{cloneVideoQualityOptions.map((option) => (
<button
key={option.key}
type="button"
className={cloneVideoQuality === option.key ? "is-active" : ""}
aria-pressed={cloneVideoQuality === option.key}
onClick={() => setCloneVideoQuality(option.key)}
>
<strong>{option.label}</strong>
<span>{option.desc}</span>
</button>
))}
</div>
</div>
<div className="clone-ai-video-section">
<div className="clone-ai-video-title-row">
<span className="clone-ai-video-title"></span>
<strong>{cloneVideoDuration}</strong>
</div>
<div className="clone-ai-duration-control" style={cloneVideoDurationStyle}>
<input
type="range"
min={cloneVideoDurationMin}
max={cloneVideoDurationMax}
step={5}
value={cloneVideoDuration}
onChange={(event) => setCloneVideoDuration(clampCloneVideoDuration(Number(event.target.value)))}
aria-label="短视频时长"
/>
<div className="clone-ai-duration-scale" aria-hidden="true">
<span>5</span>
<span>15</span>
<span>30</span>
<span>45</span>
</div>
</div>
</div>
<button
type="button"
className={`clone-ai-video-smart${cloneVideoSmart ? " is-on" : ""}`}
aria-pressed={cloneVideoSmart}
onClick={() => setCloneVideoSmart((current) => !current)}
>
<span>
<strong></strong>
<em></em>
</span>
<i aria-hidden="true" />
</button>
</section>
) : null}
{cloneOutput === "video" && onStartVideoPlan ? (
<button type="button" className="clone-ai-generate" onClick={onStartVideoPlan}>
</button>
) : null}
{cloneOutput === "video-outfit" ? (
<section className="clone-ai-video-panel" aria-label="视频换装">
<div className="clone-ai-video-section">
<span className="clone-ai-video-title"></span>
<div className="clone-ai-video-outfit-upload">
<input
ref={videoOutfitVideoRef}
type="file"
accept="video/*"
onChange={handleVideoOutfitVideoChange}
style={{ display: "none" }}
/>
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitVideoRef.current?.click()}>
{videoOutfitVideoUrl ? "重新选择视频" : "选择视频文件"}
</button>
{videoOutfitVideoUrl ? <span className="clone-ai-video-outfit-info"></span> : null}
</div>
</div>
<div className="clone-ai-video-section">
<span className="clone-ai-video-title">/</span>
<div className="clone-ai-video-outfit-upload">
<input
ref={videoOutfitRefRef}
type="file"
accept="image/*"
onChange={handleVideoOutfitRefChange}
style={{ display: "none" }}
/>
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitRefRef.current?.click()}>
{videoOutfitRefUrl ? "重新选择参考图" : "选择参考图"}
</button>
{videoOutfitRefUrl ? <span className="clone-ai-video-outfit-info"></span> : null}
</div>
</div>
</section>
) : null}
<button type="button" className="clone-ai-generate" disabled={!canGenerate || cloneOutput === "video"} onClick={status === "failed" && lastFailedActionRef.current ? lastFailedActionRef.current : handleGenerate} style={cloneOutput === "video" ? { display: "none" } : undefined}>
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <ReloadOutlined /> : null}
{status === "generating" ? "生成中..." : status === "failed" ? "重新生成" : cloneOutput === "video-outfit" ? "✦ 开始换装" : "✦ 开始生成"}
</button>
</div>
</>
);
}
export default EcommerceClonePanel;
@@ -1,4 +1,168 @@
function EcommerceDetailPanel(_props: Record<string, unknown>) {
return <div style={{ padding: 24 }}> - </div>;
import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons";
import type { ChangeEvent, RefObject } from "react";
import { EcommerceProgressBar } from "../EcommerceProgressBar";
interface EcommerceDetailPanelProps {
detailInputRef: RefObject<HTMLInputElement>;
detailProductImages: Array<{ id: string; src: string; name: string }>;
detailPlatform: string;
detailMarket: string;
detailLanguage: string;
detailType: string;
detailRequirement: string;
selectedDetailModules: string[];
detailStatus: string;
canGenerateDetail: boolean;
detailPrimaryLabel: string;
platformOptions: string[];
marketOptions: string[];
detailLanguageOptions: string[];
detailTypeOptions: string[];
detailModules: Array<{ id: string; title: string; desc: string }>;
handleDetailUpload: (event: ChangeEvent<HTMLInputElement>) => void;
handleDetailPlatformChange: (value: string) => void;
handleDetailMarketChange: (value: string) => void;
setDetailLanguage: (value: string) => void;
setDetailType: (value: string) => void;
setDetailRequirement: (value: string) => void;
handleDetailAiWrite: () => void;
toggleDetailModule: (id: string) => void;
handleDetailGenerate: () => void;
}
export default function EcommerceDetailPanel({
detailInputRef,
detailProductImages,
detailPlatform,
detailMarket,
detailLanguage,
detailType,
detailRequirement,
selectedDetailModules,
detailStatus,
canGenerateDetail,
detailPrimaryLabel,
platformOptions,
marketOptions,
detailLanguageOptions,
detailTypeOptions,
detailModules,
handleDetailUpload,
handleDetailPlatformChange,
handleDetailMarketChange,
setDetailLanguage,
setDetailType,
setDetailRequirement,
handleDetailAiWrite,
toggleDetailModule,
handleDetailGenerate,
}: EcommerceDetailPanelProps) {
return (
<>
<div className="product-clone-panel__scroll">
<section className="product-clone-field">
<h2>
<QuestionCircleOutlined />
</h2>
<button type="button" className="product-clone-upload-zone product-detail-upload" onClick={() => detailInputRef.current?.click()}>
<strong>
<CloudUploadOutlined />
</strong>
<span>3</span>
</button>
<input ref={detailInputRef} type="file" accept="image/*" multiple onChange={handleDetailUpload} />
{detailProductImages.length ? (
<div className="product-clone-thumb-row" aria-label="已上传商品原图">
{detailProductImages.map((item) => (
<figure key={item.id} className="product-clone-uploaded-thumb">
<img src={item.src} alt={item.name} />
<span className="uploaded-image-zoom" aria-hidden="true">
<img src={item.src} alt="" />
</span>
</figure>
))}
</div>
) : null}
</section>
<section className="product-clone-field">
<h2></h2>
<div className="product-detail-settings-grid">
<select value={detailPlatform} onChange={(event) => handleDetailPlatformChange(event.target.value)}>
{platformOptions.map((item) => (
<option key={item}>{item}</option>
))}
</select>
<select value={detailMarket} onChange={(event) => handleDetailMarketChange(event.target.value)}>
{marketOptions.map((item) => (
<option key={item}>{item}</option>
))}
</select>
<select value={detailLanguage} onChange={(event) => setDetailLanguage(event.target.value)}>
{detailLanguageOptions.map((item) => (
<option key={item}>{item}</option>
))}
</select>
<select value={detailType} onChange={(event) => setDetailType(event.target.value)}>
{detailTypeOptions.map((item) => (
<option key={item}>{item}</option>
))}
</select>
</div>
</section>
<section className="product-clone-field product-detail-requirement">
<h2>
&
<QuestionCircleOutlined />
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleDetailAiWrite();
}}
>
AI
</button>
</h2>
<textarea
value={detailRequirement}
onChange={(event) => setDetailRequirement(event.target.value)}
placeholder={"建议包含以下信息生成更精准:\n1.产品名称\n2.核心卖点\n3.适用人群\n4.期望场景\n5.具体参数"}
/>
</section>
<section className="product-clone-field">
<h2>
<QuestionCircleOutlined />
</h2>
<div className="product-detail-module-grid">
{detailModules.map((module) => (
<button
key={module.id}
type="button"
className={selectedDetailModules.includes(module.id) ? "is-active" : ""}
onClick={() => toggleDetailModule(module.id)}
>
<strong>{module.title}</strong>
<span>{module.desc}</span>
</button>
))}
</div>
</section>
</div>
<footer className="product-clone-panel__footer">
{detailStatus === "generating" ? <EcommerceProgressBar status="generating" label="A+详情页" /> : null}
<button type="button" className="product-clone-primary" disabled={!canGenerateDetail} onClick={handleDetailGenerate}>
{detailStatus === "generating" ? <LoadingOutlined /> : null}
{detailPrimaryLabel}
</button>
</footer>
</>
);
}
export default EcommerceDetailPanel;
@@ -1,4 +1,171 @@
function EcommerceSetPanel(_props: Record<string, unknown>) {
return <div style={{ padding: 24 }}> - </div>;
import { CloudUploadOutlined, CloseOutlined, FileImageOutlined, SettingOutlined } from "@ant-design/icons";
import type { ChangeEvent, DragEvent, RefObject } from "react";
type ProductSetOutputKey = "set" | "detail" | "model" | "video";
interface EcommerceSetPanelProps {
setInputRef: RefObject<HTMLInputElement>;
setImages: Array<{ id: string; src: string; name: string }>;
isSetUploadDragging: boolean;
productSetOutputOptions: Array<{ key: ProductSetOutputKey; label: string }>;
productSetOutput: ProductSetOutputKey;
platformOptions: string[];
marketOptions: string[];
productSetLanguageOptions: string[];
productSetRatioOptions: string[];
productSetPlatform: string;
productSetMarket: string;
productSetLanguage: string;
productSetRatio: string;
setIsSetUploadDragging: (value: boolean) => void;
handleSetDrop: (event: DragEvent<HTMLButtonElement>) => void;
handleSetUpload: (event: ChangeEvent<HTMLInputElement>) => void;
removeSetImage: (id: string) => void;
handleProductSetOutputChange: (value: ProductSetOutputKey) => void;
handleProductSetPlatformChange: (value: string) => void;
handleProductSetMarketChange: (value: string) => void;
setProductSetLanguage: (value: string) => void;
setProductSetRatio: (value: string) => void;
formatRatioDisplayValue: (value: string) => string;
}
export default function EcommerceSetPanel({
setInputRef,
setImages,
isSetUploadDragging,
productSetOutputOptions,
productSetOutput,
platformOptions,
marketOptions,
productSetLanguageOptions,
productSetRatioOptions,
productSetPlatform,
productSetMarket,
productSetLanguage,
productSetRatio,
setIsSetUploadDragging,
handleSetDrop,
handleSetUpload,
removeSetImage,
handleProductSetOutputChange,
handleProductSetPlatformChange,
handleProductSetMarketChange,
setProductSetLanguage,
setProductSetRatio,
formatRatioDisplayValue,
}: EcommerceSetPanelProps) {
return (
<>
<div className="product-clone-panel__scroll">
<section className="product-clone-field product-set-upload-section">
<h2>
<CloudUploadOutlined />
</h2>
<button
type="button"
className={`product-clone-upload-zone product-set-upload${isSetUploadDragging ? " is-dragging" : ""}`}
onClick={() => setInputRef.current?.click()}
onDragEnter={(event) => {
event.preventDefault();
setIsSetUploadDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setIsSetUploadDragging(false)}
onDrop={handleSetDrop}
>
<span className="product-set-upload-icon">
<FileImageOutlined />
</span>
<span className="product-set-upload-title"></span>
<strong>
<span aria-hidden="true">+</span>
</strong>
<span className="product-set-upload-note"> 3 </span>
</button>
<input ref={setInputRef} type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={handleSetUpload} />
{setImages.length ? (
<div className="product-clone-thumb-row product-set-thumb-row" aria-label="已上传商品原图">
{setImages.map((item) => (
<figure key={item.id} className="product-set-thumb">
<img src={item.src} alt={item.name} />
<span className="uploaded-image-zoom" aria-hidden="true">
<img src={item.src} alt="" />
</span>
<button type="button" onClick={() => removeSetImage(item.id)} aria-label={`删除${item.name}`}>
<CloseOutlined />
</button>
</figure>
))}
</div>
) : null}
</section>
<section className="product-clone-field product-set-settings-section">
<h2>
<SettingOutlined />
</h2>
<div className="product-set-setting-block">
<span className="product-set-setting-title"></span>
<div className="product-set-output-grid" role="radiogroup" aria-label="生成内容">
{productSetOutputOptions.map((option) => (
<button
key={option.key}
type="button"
className={productSetOutput === option.key ? "is-active" : ""}
aria-pressed={productSetOutput === option.key}
onClick={() => handleProductSetOutputChange(option.key)}
>
{option.label}
</button>
))}
</div>
</div>
<div className="product-set-setting-block">
<span className="product-set-setting-title"></span>
<div className="product-set-field-grid">
<label>
<span></span>
<select value={productSetPlatform} onChange={(event) => handleProductSetPlatformChange(event.target.value)}>
{platformOptions.map((item) => (
<option key={item}>{item}</option>
))}
</select>
</label>
<label>
<span></span>
<select value={productSetMarket} onChange={(event) => handleProductSetMarketChange(event.target.value)}>
{marketOptions.map((item) => (
<option key={item}>{item}</option>
))}
</select>
</label>
<label>
<span></span>
<select value={productSetLanguage} onChange={(event) => setProductSetLanguage(event.target.value)}>
{productSetLanguageOptions.map((item) => (
<option key={item}>{item}</option>
))}
</select>
</label>
<label>
<span>/</span>
<select
value={productSetRatio}
onChange={(event) => setProductSetRatio(event.target.value)}
disabled={productSetRatioOptions.length <= 1}
>
{productSetRatioOptions.map((item) => (
<option key={item} value={item}>{formatRatioDisplayValue(item)}</option>
))}
</select>
</label>
</div>
</div>
</section>
</div>
</>
);
}
export default EcommerceSetPanel;
@@ -1,4 +1,219 @@
function EcommerceTryOnPanel(_props: Record<string, unknown>) {
return <div style={{ padding: 24 }}>AI 穿 - </div>;
import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons";
import type { ChangeEvent, RefObject } from "react";
import { EcommerceProgressBar } from "../EcommerceProgressBar";
interface EcommerceTryOnPanelProps {
garmentInputRef: RefObject<HTMLInputElement>;
garmentImages: Array<{ id: string; src: string; name: string }>;
modelSource: string;
modelGender: string;
modelAge: string;
modelEthnicity: string;
modelBody: string;
appearance: string;
selectedScenes: string[];
customScene: string;
smartScene: boolean;
tryOnRatio: string;
tryOnStatus: string;
canGenerateTryOn: boolean;
tryOnPrimaryLabel: string;
tryOnModelOptions: { gender: string[]; age: string[]; ethnicity: string[]; body: string[] };
tryOnAssets: { modelWoman: string; modelMan: string; modelAsian: string };
tryOnScenes: string[];
tryOnRatioOptions: string[];
handleGarmentUpload: (event: ChangeEvent<HTMLInputElement>) => void;
setModelSource: (value: "ai" | "library") => void;
setModelGender: (value: string) => void;
setModelAge: (value: string) => void;
setModelEthnicity: (value: string) => void;
setModelBody: (value: string) => void;
setAppearance: (value: string) => void;
handleGenerateModel: () => void;
toggleScene: (scene: string) => void;
setCustomScene: (value: string) => void;
setSmartScene: (updater: (current: boolean) => boolean) => void;
setTryOnRatio: (value: string) => void;
handleTryOnGenerate: () => void;
}
export default function EcommerceTryOnPanel({
garmentInputRef,
garmentImages,
modelSource,
modelGender,
modelAge,
modelEthnicity,
modelBody,
appearance,
selectedScenes,
customScene,
smartScene,
tryOnRatio,
tryOnStatus,
canGenerateTryOn,
tryOnPrimaryLabel,
tryOnModelOptions,
tryOnAssets,
tryOnScenes,
tryOnRatioOptions,
handleGarmentUpload,
setModelSource,
setModelGender,
setModelAge,
setModelEthnicity,
setModelBody,
setAppearance,
handleGenerateModel,
toggleScene,
setCustomScene,
setSmartScene,
setTryOnRatio,
handleTryOnGenerate,
}: EcommerceTryOnPanelProps) {
return (
<>
<div className="product-clone-panel__scroll">
<section className="product-clone-field">
<h2></h2>
<button type="button" className="product-clone-upload-zone product-try-on-upload" onClick={() => garmentInputRef.current?.click()}>
<strong>
<CloudUploadOutlined />
</strong>
<span>5</span>
</button>
<input ref={garmentInputRef} type="file" accept="image/*" multiple onChange={handleGarmentUpload} />
{garmentImages.length ? (
<div className="product-clone-thumb-row product-try-on-thumb-row" aria-label="已上传服装图片">
{garmentImages.map((item) => (
<figure key={item.id} className="product-clone-uploaded-thumb">
<img src={item.src} alt={item.name} />
<span className="uploaded-image-zoom" aria-hidden="true">
<img src={item.src} alt="" />
</span>
</figure>
))}
</div>
) : null}
</section>
<section className="product-clone-field">
<h2></h2>
<div className="product-clone-segment" role="tablist" aria-label="模特来源">
<button type="button" className={modelSource === "ai" ? "is-active" : ""} onClick={() => setModelSource("ai")}>
AI
</button>
<button type="button" className={modelSource === "library" ? "is-active" : ""} onClick={() => setModelSource("library")}>
<QuestionCircleOutlined />
</button>
</div>
{modelSource === "ai" ? (
<>
<div className="product-clone-model-grid">
<select value={modelGender} onChange={(event) => setModelGender(event.target.value)}>
{tryOnModelOptions.gender.map((item) => (
<option key={item}>{item}</option>
))}
</select>
<select value={modelAge} onChange={(event) => setModelAge(event.target.value)}>
{tryOnModelOptions.age.map((item) => (
<option key={item}>{item}</option>
))}
</select>
<select value={modelEthnicity} onChange={(event) => setModelEthnicity(event.target.value)}>
{tryOnModelOptions.ethnicity.map((item) => (
<option key={item}>{item}</option>
))}
</select>
<select value={modelBody} onChange={(event) => setModelBody(event.target.value)}>
{tryOnModelOptions.body.map((item) => (
<option key={item}>{item}</option>
))}
</select>
</div>
<label className="product-try-on-textarea-label">
<span></span>
<textarea
value={appearance}
onChange={(event) => setAppearance(event.target.value)}
placeholder="例如:小麦色皮肤、齐刘海、眼角有泪痣..."
/>
</label>
<button type="button" className="product-clone-model-button" onClick={handleGenerateModel} disabled={tryOnStatus === "modeling"}>
{tryOnStatus === "modeling" ? <LoadingOutlined /> : null}
{tryOnStatus === "modeling" ? "生成中..." : "生成基准模特"}
</button>
</>
) : (
<div className="product-try-on-library" aria-label="模特库">
{[tryOnAssets.modelWoman, tryOnAssets.modelMan, tryOnAssets.modelAsian].map((src, index) => (
<button key={src} type="button" className={index === 0 ? "is-active" : ""}>
<img src={src} alt={`模特 ${index + 1}`} />
</button>
))}
</div>
)}
</section>
<section className="product-clone-field">
<h2></h2>
<div className="product-clone-scene-grid">
{tryOnScenes.map((scene) => (
<button
key={scene}
type="button"
className={selectedScenes.includes(scene) ? "is-active" : ""}
onClick={() => toggleScene(scene)}
>
<span aria-hidden="true" />
{scene}
</button>
))}
</div>
</section>
<label className="product-clone-field product-try-on-scene-field">
<h2></h2>
<textarea
value={customScene}
onChange={(event) => setCustomScene(event.target.value)}
placeholder="描述你想要的场景:如秋季枫叶小径、暖色调午后阳光、模特倚靠树干..."
/>
</label>
<section className="product-clone-field">
<button type="button" className="product-clone-switch-row" onClick={() => setSmartScene((current) => !current)}>
<span>
<strong></strong>
<em></em>
</span>
<span className={`product-clone-switch${smartScene ? " is-on" : ""}`} role="switch" aria-checked={smartScene}>
<span />
</span>
</button>
</section>
<section className="product-clone-field">
<h2></h2>
<div className="product-clone-ratio-row">
{tryOnRatioOptions.map((item) => (
<button key={item} type="button" className={tryOnRatio === item ? "is-active" : ""} onClick={() => setTryOnRatio(item)}>
{item}
</button>
))}
</div>
</section>
</div>
<footer className="product-clone-panel__footer">
{tryOnStatus === "generating" ? <EcommerceProgressBar status="generating" label="服饰穿戴图" /> : null}
<button type="button" className="product-clone-primary" disabled={!canGenerateTryOn} onClick={handleTryOnGenerate}>
{tryOnStatus === "generating" ? <LoadingOutlined /> : null}
{tryOnPrimaryLabel}
</button>
</footer>
</>
);
}
export default EcommerceTryOnPanel;
@@ -0,0 +1,185 @@
import { useCallback, useEffect, useState } from "react";
import {
CloseOutlined,
DeleteOutlined,
ExclamationCircleOutlined,
HistoryOutlined,
LoadingOutlined,
PlayCircleOutlined,
} from "@ant-design/icons";
import {
fetchVideoHistory,
deleteVideoHistory,
type VideoHistoryItem,
} from "../ecommerceVideoService";
interface EcommerceVideoHistoryPanelProps {
visible: boolean;
onClose: () => void;
}
export default function EcommerceVideoHistoryPanel({
visible,
onClose,
}: EcommerceVideoHistoryPanelProps) {
const [items, setItems] = useState<VideoHistoryItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [offset, setOffset] = useState(0);
const [previewMedia, setPreviewMedia] = useState<{
url: string;
type: "image" | "video";
} | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
const limit = 10;
const load = useCallback(async (off: number) => {
setLoading(true);
try {
const res = await fetchVideoHistory(limit, off);
setItems(res.items);
setTotal(res.total);
setOffset(off);
} catch { /* silent */ }
setLoading(false);
}, []);
useEffect(() => {
if (visible) load(0);
}, [visible, load]);
const handleDelete = async (id: number) => {
try {
await deleteVideoHistory(id);
setItems((prev) => prev.filter((i) => i.id !== id));
setTotal((t) => t - 1);
} catch { /* silent */ }
setConfirmDeleteId(null);
};
if (!visible) return null;
const totalPages = Math.ceil(total / limit);
const currentPage = Math.floor(offset / limit) + 1;
return (
<>
<div className="ecom-video-history-panel">
<div className="ecom-video-history-panel__header">
<HistoryOutlined />
<span></span>
<button className="ecom-video-history-panel__close" onClick={onClose}>
<CloseOutlined />
</button>
</div>
<div className="ecom-video-history-panel__body">
{loading && !items.length ? (
<div className="ecom-video-history-panel__empty">
<LoadingOutlined style={{ fontSize: 24 }} />
<span>...</span>
</div>
) : !items.length ? (
<div className="ecom-video-history-panel__empty">
<HistoryOutlined style={{ fontSize: 32, opacity: 0.3 }} />
<span></span>
</div>
) : (
items.map((item) => (
<div key={item.id} className="ecom-video-history-card">
<div className="ecom-video-history-card__header">
<span className="ecom-video-history-card__title">
{item.title || "未命名"}
</span>
<span className="ecom-video-history-card__date">
{new Date(item.createdAt).toLocaleDateString("zh-CN")}
</span>
<button
className="ecom-video-history-card__delete"
onClick={() => setConfirmDeleteId(item.id)}
title="删除"
>
<DeleteOutlined />
</button>
</div>
<div className="ecom-video-history-card__scenes">
{item.scenes.map((scene, idx) => (
<div key={idx} className="ecom-video-history-card__scene">
{scene.imageUrl && (
<img
src={scene.imageUrl}
alt={`分镜${idx + 1}`}
onClick={() =>
setPreviewMedia({ url: scene.imageUrl!, type: "image" })
}
/>
)}
{scene.videoUrl && (
<div
className="ecom-video-history-card__video-thumb"
onClick={() =>
setPreviewMedia({ url: scene.videoUrl!, type: "video" })
}
>
<PlayCircleOutlined />
</div>
)}
</div>
))}
</div>
</div>
))
)}
</div>
{totalPages > 1 && (
<div className="ecom-video-history-panel__pager">
<button disabled={currentPage <= 1} onClick={() => load(offset - limit)}>
</button>
<span>{currentPage}/{totalPages}</span>
<button disabled={currentPage >= totalPages} onClick={() => load(offset + limit)}>
</button>
</div>
)}
</div>
{confirmDeleteId !== null && (
<div className="ecom-video-confirm-dialog-backdrop" onClick={() => setConfirmDeleteId(null)}>
<div className="ecom-video-confirm-dialog" onClick={(e) => e.stopPropagation()}>
<ExclamationCircleOutlined className="ecom-video-confirm-dialog__icon" />
<p className="ecom-video-confirm-dialog__text">
</p>
<div className="ecom-video-confirm-dialog__actions">
<button onClick={() => setConfirmDeleteId(null)}></button>
<button className="is-danger" onClick={() => handleDelete(confirmDeleteId)}>
</button>
</div>
</div>
</div>
)}
{previewMedia && (
<div
className="ecom-video-preview-overlay"
onClick={() => setPreviewMedia(null)}
>
<button
className="ecom-video-preview-overlay__close"
onClick={() => setPreviewMedia(null)}
>
<CloseOutlined />
</button>
{previewMedia.type === "image" ? (
<img src={previewMedia.url} alt="preview" />
) : (
<video src={previewMedia.url} controls autoPlay />
)}
</div>
)}
</>
);
}
+8 -8
View File
@@ -10,6 +10,7 @@ import {
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
import { useScrollEntrance } from "../../hooks/useScrollEntrance";
import { ossAssets } from "../../data/ossAssets";
import WelcomeSplash from "./WelcomeSplash";
import ToolboxSection from "./ToolboxSection";
import ScriptReviewShowcase from "./ScriptReviewShowcase";
@@ -24,13 +25,12 @@ function ScrollEntrance({ children, className, ...rest }: { children: React.Reac
);
}
const OSS_MUBAN = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban";
const heroImage1 = `${OSS_MUBAN}/hero-1.png`;
const heroImage2 = `${OSS_MUBAN}/hero-2.png`;
const heroImage3 = `${OSS_MUBAN}/hero-3.png`;
const featureEcommerceImage = `${OSS_MUBAN}/feature-ecommerce.jpg`;
const featureScriptImage = `${OSS_MUBAN}/feature-script.jpg`;
const featureTokenImage = `${OSS_MUBAN}/feature-token.jpg`;
const [heroImage1, heroImage2, heroImage3] = ossAssets.home.heroSlides;
const {
ecommerce: featureEcommerceImage,
script: featureScriptImage,
token: featureTokenImage,
} = ossAssets.home.features;
interface HomePageProps {
onOpenGenerate: () => void;
@@ -42,7 +42,7 @@ interface HomePageProps {
onOpenImageTool?: (tool: WebImageWorkbenchTool) => void;
}
const HOME_BACKGROUND_VIDEO = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban/hero-bg.mp4";
const HOME_BACKGROUND_VIDEO = ossAssets.home.backgroundVideo;
const HOME_CAROUSEL_IMAGES = [
{ imageUrl: heroImage1, title: "灵感生成" },
+8 -4
View File
@@ -1,9 +1,13 @@
import { ToolOutlined } from "@ant-design/icons";
import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
import toolImageBefore from "../../assets/toolbox/牛仔.png";
import toolImageAfter from "../../assets/toolbox/西装.png";
import watermarkBefore from "../../assets/toolbox/去水印前.png";
import watermarkAfter from "../../assets/toolbox/去水印后.png";
import { ossAssets } from "../../data/ossAssets";
const {
imageBefore: toolImageBefore,
imageAfter: toolImageAfter,
watermarkBefore,
watermarkAfter,
} = ossAssets.toolbox;
interface ToolboxSectionProps {
onSelectView: (view: WebViewKey) => void;
@@ -148,22 +148,21 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
keepaliveRestoredRef.current = true;
const saved = loadToolTaskState("imagewb");
if (!saved || saved.resultUrl) return;
setIsGenerating(true);
setGenerating(true);
abortRef.current = false;
taskIdRef.current = saved.taskId;
void waitForTask(saved.taskId, {
onProgress: (e) => {
setTaskProgress(Math.max(0, Math.min(100, Math.trunc(e.progress || 0))));
setStatus(`${e.status} / ${e.progress}%`);
if (e.status === "completed" && e.resultUrl) {
setResultImages([e.resultUrl]);
clearToolTaskState("imagewb");
setIsGenerating(false);
setGenerating(false);
setStatus("恢复任务完成");
}
if (e.status === "failed") {
clearToolTaskState("imagewb");
setIsGenerating(false);
setGenerating(false);
setStatus("恢复任务失败");
}
},
+3 -2
View File
@@ -20,6 +20,7 @@ import { assetClient } from "../../api/assetClient";
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
import { keyServerClient } from "../../api/keyServerClient";
import { isServerRequestError } from "../../api/serverConnection";
import { ossAssets } from "../../data/ossAssets";
import type { WebAuthMode, WebGenerationPreviewTask, WebProjectSummary, WebUsageSummary, WebUserSession } from "../../types";
import type { SavedAssetItem } from "../assets/localAssetStore";
@@ -44,8 +45,8 @@ type ProfilePanel = "works" | "projects" | "assets" | "community";
type AccountPanel = "credits" | "tasks";
const PROFILE_LOCAL_STORAGE_PREFIX = "omniai-web-profile-ui";
const AUTH_LOGO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png";
const AUTH_SHOWCASE_VIDEO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/test5.mp4";
const AUTH_LOGO_URL = ossAssets.brand.logo;
const AUTH_SHOWCASE_VIDEO_URL = ossAssets.auth.showcaseVideo;
function profileStorageKey(userId: string | number | undefined, field: "avatar" | "bio" | "background"): string {
return `${PROFILE_LOCAL_STORAGE_PREFIX}:${userId ?? "guest"}:${field}`;
+22 -4
View File
@@ -1,5 +1,6 @@
import { CheckCircleOutlined, FlagOutlined, MailOutlined, PhoneOutlined } from "@ant-design/icons";
import { useState, type FormEvent } from "react";
import { useEffect, useState, type FormEvent } from "react";
import { publicConfigClient, type WebPublicConfig } from "../../api/publicConfigClient";
import { reportClient, type ReportInput } from "../../api/reportClient";
type SubmitState = "idle" | "loading" | "success" | "error";
@@ -31,6 +32,7 @@ function ReportPage() {
const [contactPhone, setContactPhone] = useState("");
const [submitState, setSubmitState] = useState<SubmitState>("idle");
const [errorMsg, setErrorMsg] = useState("");
const [publicConfig, setPublicConfig] = useState<WebPublicConfig>({});
const canSubmit =
submitState !== "loading" && reportType !== "" && title.trim() !== "" && description.trim() !== "";
@@ -48,6 +50,22 @@ function ReportPage() {
setErrorMsg("");
};
useEffect(() => {
let cancelled = false;
publicConfigClient
.get()
.then((config) => {
if (!cancelled) setPublicConfig(config);
})
.catch(() => {
if (!cancelled) setPublicConfig({});
});
return () => {
cancelled = true;
};
}, []);
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
if (!canSubmit) return;
@@ -85,9 +103,9 @@ function ReportPage() {
</header>
<div className="report-contact-strip">
<span><MailOutlined /> {import.meta.env.VITE_REPORT_EMAIL || "support@omniai.com"}</span>
<span><PhoneOutlined /> {import.meta.env.VITE_REPORT_PHONE || "请在环境变量配置客服电话"}</span>
<span>{import.meta.env.VITE_ICP_RECORD || "ICP备案信息待配置"}</span>
<span><MailOutlined /> {publicConfig.contactEmail || "由服务器配置"}</span>
<span><PhoneOutlined /> {publicConfig.contactPhone || "由服务器配置"}</span>
<span>{publicConfig.icpRecord || "由服务器配置"}</span>
</div>
{submitState === "success" ? (
+16 -66
View File
@@ -10,6 +10,7 @@ import {
} from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
import { evaluateScript } from "../../api/scriptEvalClient";
import { buildApiUrl, getStoredToken } from "../../api/serverConnection";
import { useSessionStore } from "../../stores";
interface ScoreDimension {
@@ -175,61 +176,6 @@ function normalizeUploadedText(raw: string, ext: string): string {
return raw;
}
async function extractDocxText(bytes: Uint8Array): Promise<string> {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const entries: Array<{ name: string; offset: number; size: number; compressed: boolean }> = [];
let pos = 0;
while (pos < bytes.length - 30) {
if (view.getUint32(pos, true) !== 0x04034b50) break;
const compressed = view.getUint16(pos + 10, true) !== 0;
const compressedSize = view.getUint32(pos + 18, true);
const fileNameLen = view.getUint16(pos + 26, true);
const extraLen = view.getUint16(pos + 28, true);
const name = new TextDecoder().decode(bytes.slice(pos + 30, pos + 30 + fileNameLen));
const dataStart = pos + 30 + fileNameLen + extraLen;
entries.push({ name, offset: dataStart, size: compressedSize, compressed });
pos = dataStart + compressedSize;
}
const docEntry = entries.find((e) => e.name === "word/document.xml");
if (!docEntry) return "";
const xmlBytes = bytes.slice(docEntry.offset, docEntry.offset + docEntry.size);
let xmlText: string;
if (docEntry.compressed) {
try {
const ds = new DecompressionStream("deflate-raw");
const writer = ds.writable.getWriter();
writer.write(xmlBytes);
writer.close();
const reader = ds.readable.getReader();
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const totalLen = chunks.reduce((s, c) => s + c.length, 0);
const combined = new Uint8Array(totalLen);
let offset = 0;
for (const c of chunks) { combined.set(c, offset); offset += c.length; }
xmlText = new TextDecoder().decode(combined);
} catch {
xmlText = new TextDecoder().decode(xmlBytes);
}
} else {
xmlText = new TextDecoder().decode(xmlBytes);
}
const textMatches = xmlText.match(/<w:t[^>]*>([\s\S]*?)<\/w:t>/g);
if (!textMatches) return "";
const paraMatches = xmlText.match(/<w:p[ >][\s\S]*?<\/w:p>/g);
if (paraMatches) {
return paraMatches.map((p) => {
const tMatches = p.match(/<w:t[^>]*>([\s\S]*?)<\/w:t>/g);
if (!tMatches) return "";
return tMatches.map((m) => m.replace(/<[^>]+>/g, "").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&quot;/g, "\"")).join("");
}).filter(Boolean).join("\n").trim();
}
return "";
}
function formatFileSize(size: number): string {
if (size < 1024) return `${size} B`;
@@ -321,22 +267,26 @@ function ScriptTokensPage() {
const ext = getFileExtension(file.name);
const readable = isReadableTextFile(file, ext);
setUploadedFile({ name: file.name, size: file.size });
if (ext === ".docx") {
if (ext === ".docx" || ext === ".doc") {
try {
const bytes = new Uint8Array(await file.arrayBuffer());
const text = await extractDocxText(bytes);
if (text) {
setScript(text);
const formData = new FormData();
formData.append("file", file);
const token = getStoredToken();
const resp = await fetch(buildApiUrl("files/extract-text"), {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
});
if (resp.ok) {
const { text } = await resp.json();
setScript(text || "");
} else {
setScript(`[已上传文件:${file.name}]\n\n无法从 DOCX 文件中提取文本,请尝试另存为 TXT 格式后重新上传。`);
const err = await resp.json().catch(() => ({ error: "解析失败" }));
setScript(`[已上传文件:${file.name}]\n\n${err.error || "文件解析失败,请尝试另存为 TXT 格式后重新上传。"}`);
}
} catch {
setScript(`[已上传文件:${file.name}]\n\n解析 DOCX 文件失败,请尝试另存为 TXT 格式后重新上传`);
setScript(`[已上传文件:${file.name}]\n\n文件解析请求失败,请检查网络连接后重试`);
}
} else if (ext === ".doc") {
const text = await decodeTextFile(file);
const cleaned = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "").replace(/\s{3,}/g, "\n\n").trim();
setScript(cleaned || `[已上传文件:${file.name}]\n\n无法从 .doc 文件中提取文本,请另存为 .docx 或 .txt 格式。`);
} else if (readable) {
const text = normalizeUploadedText(await decodeTextFile(file), ext);
setScript(text);
@@ -142,6 +142,8 @@ function TokenUsagePage({
onSelectView,
}: TokenUsagePageProps) {
const [enterpriseUsage, setEnterpriseUsage] = useState<WebEnterpriseUsageSummary | null>(null);
const [enterpriseUsageLoading, setEnterpriseUsageLoading] = useState(false);
const [enterpriseUsageError, setEnterpriseUsageError] = useState<string | null>(null);
const isEnterpriseAdmin = session?.user.enterpriseRole === "admin";
const isEnterpriseAccount = Boolean(session?.user.enterpriseId || session?.user.accountType === "enterprise");
@@ -152,10 +154,15 @@ function TokenUsagePage({
setEnterpriseUsage(null);
return;
}
setEnterpriseUsageLoading(true);
setEnterpriseUsageError(null);
try {
setEnterpriseUsage(await loader());
} catch (error) {
setEnterpriseUsage(null);
setEnterpriseUsageError(error instanceof Error ? error.message : "加载失败");
} finally {
setEnterpriseUsageLoading(false);
}
}, [session, isEnterpriseAdmin, loadEnterpriseUsage, loadPersonalUsage]);
+6 -43
View File
@@ -237,7 +237,6 @@ function WorkbenchPage({
const keepaliveTasksRef = useRef<Record<string, WorkbenchKeepaliveTask>>(readStoredKeepaliveTasks());
const taskAbortControllersRef = useRef<Map<string, AbortController>>(new Map());
const lastScrollTopRef = useRef(0);
const scrollActionsHideTimerRef = useRef<number | null>(null);
const shouldFollowNewMessagesRef = useRef(true);
const pendingScrollToLatestRef = useRef(true);
const genTracker = useGenerationTasks({ sourceView: "workbench" });
@@ -278,8 +277,6 @@ function WorkbenchPage({
const [promptSelectionRange, setPromptSelectionRange] = useState({ start: 0, end: 0 });
const [mentionActiveIndex, setMentionActiveIndex] = useState(0);
const [composerHidden, setComposerHidden] = useState(false);
const [scrollActionsVisible, setScrollActionsVisible] = useState(false);
const [scrollActionDirection, setScrollActionDirection] = useState<"top" | "bottom" | null>(null);
const [workspaceStarted, setWorkspaceStarted] = useState(false);
useEffect(() => {
@@ -448,27 +445,6 @@ function WorkbenchPage({
"--accent-glow": `0 0 24px rgba(${accentRgb}, 0.22)`,
} as CSSProperties;
const revealScrollActionsTemporarily = useCallback((direction: "top" | "bottom") => {
setScrollActionDirection(direction);
setScrollActionsVisible(true);
if (scrollActionsHideTimerRef.current !== null) {
window.clearTimeout(scrollActionsHideTimerRef.current);
}
scrollActionsHideTimerRef.current = window.setTimeout(() => {
setScrollActionsVisible(false);
setScrollActionDirection(null);
scrollActionsHideTimerRef.current = null;
}, 950);
}, []);
useEffect(() => {
return () => {
if (scrollActionsHideTimerRef.current !== null) {
window.clearTimeout(scrollActionsHideTimerRef.current);
}
};
}, []);
const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior = "smooth") => {
const scroll = () => {
const surface = messagesSurfaceRef.current;
@@ -479,7 +455,6 @@ function WorkbenchPage({
setComposerHidden(false);
shouldFollowNewMessagesRef.current = true;
revealScrollActionsTemporarily("bottom");
surface.scrollTo({ top: surface.scrollHeight, behavior });
lastScrollTopRef.current = surface.scrollTop;
};
@@ -488,7 +463,7 @@ function WorkbenchPage({
scroll();
window.setTimeout(scroll, 80);
});
}, [revealScrollActionsTemporarily]);
}, []);
const imageSettingGroups = useMemo<WorkbenchFieldGroup[]>(
() => [
@@ -1026,11 +1001,6 @@ function WorkbenchPage({
});
removeKeepaliveTask(task.taskId);
onRefreshUsage?.();
if (status.status === "completed") {
import("../../utils/generationNotifier").then((m) =>
m.notifyTaskCompleted(task.mode === "video" ? "视频" : "图片", task.mode as "image" | "video"),
);
}
return;
}
@@ -1402,9 +1372,6 @@ function WorkbenchPage({
const delta = top - lastScrollTopRef.current;
const atTop = top <= edgeThreshold;
const atBottom = top + surface.clientHeight >= surface.scrollHeight - edgeThreshold;
if (surface.scrollHeight > surface.clientHeight + edgeThreshold && Math.abs(delta) > 1) {
revealScrollActionsTemporarily(delta > 0 ? "bottom" : "top");
}
shouldFollowNewMessagesRef.current = atBottom;
if (atTop || atBottom) {
setComposerHidden(false);
@@ -1416,7 +1383,7 @@ function WorkbenchPage({
surface.addEventListener("scroll", handleScroll, { passive: true });
return () => surface.removeEventListener("scroll", handleScroll);
}, [hasActivatedWorkspace, revealScrollActionsTemporarily]);
}, [hasActivatedWorkspace]);
const scrollMessagesSurface = useCallback((direction: "top" | "bottom") => {
const surface = messagesSurfaceRef.current;
@@ -1424,9 +1391,8 @@ function WorkbenchPage({
const top = direction === "top" ? 0 : surface.scrollHeight;
setComposerHidden(false);
revealScrollActionsTemporarily(direction);
surface.scrollTo({ top, behavior: "smooth" });
}, [revealScrollActionsTemporarily]);
}, []);
const closeToolbarMenus = () => setToolbarMenuId(null);
const toggleToolbarMenu = (menuId: Exclude<ToolbarMenuId, null>) => {
@@ -3115,13 +3081,10 @@ function WorkbenchPage({
{renderComposerToolbar(false, isGenerating)}
</div>
</section>
<div
className={`wb-chat-scroll-actions${scrollActionsVisible ? " is-visible" : ""}${scrollActionDirection ? ` is-${scrollActionDirection}` : ""}`}
aria-label="聊天滚动"
>
<div className="wb-chat-scroll-actions" aria-label="聊天滚动">
<button
type="button"
className="wb-chat-scroll-actions__button wb-chat-scroll-actions__button--top"
className="wb-chat-scroll-actions__button"
title="返回聊天顶部"
aria-label="返回聊天顶部"
onClick={() => scrollMessagesSurface("top")}
@@ -3130,7 +3093,7 @@ function WorkbenchPage({
</button>
<button
type="button"
className="wb-chat-scroll-actions__button wb-chat-scroll-actions__button--bottom"
className="wb-chat-scroll-actions__button"
title="到达聊天底部"
aria-label="到达聊天底部"
onClick={() => scrollMessagesSurface("bottom")}