feat: 电商工作台进度与生成记录健壮性优化

This commit is contained in:
2026-06-15 10:24:31 +08:00
parent e1fdbe5f9b
commit 0b2d6b901f
10 changed files with 533 additions and 102 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN" data-theme="dark" data-ui-theme="dark-green" style="color-scheme: dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+20 -36
View File
@@ -1,4 +1,4 @@
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { import {
BugOutlined, BugOutlined,
CheckCircleFilled, CheckCircleFilled,
@@ -20,9 +20,7 @@ import {
import ErrorBoundary from "./components/ErrorBoundary"; import ErrorBoundary from "./components/ErrorBoundary";
import ToastContainer from "./components/toast/ToastContainer"; import ToastContainer from "./components/toast/ToastContainer";
import { toast } from "./components/toast/toastStore"; import { toast } from "./components/toast/toastStore";
import EcommercePage from "./features/ecommerce/EcommercePage";
import { flushPendingGenerationRecords } from "./api/generationRecordClient"; import { flushPendingGenerationRecords } from "./api/generationRecordClient";
import { ossAssets } from "./data/ossAssets";
import { keyServerClient } from "./api/keyServerClient"; import { keyServerClient } from "./api/keyServerClient";
import { setUserMaxConcurrency } from "./api/generationConcurrency"; import { setUserMaxConcurrency } from "./api/generationConcurrency";
import { import {
@@ -38,6 +36,8 @@ import { useAppStore, useSessionStore } from "./stores";
import type { WebUserSession } from "./types"; import type { WebUserSession } from "./types";
import "./styles/ecommerce-standalone.css"; import "./styles/ecommerce-standalone.css";
const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
type AuthMode = "login" | "register"; type AuthMode = "login" | "register";
type AuthMethod = "account" | "email" | "phone"; type AuthMethod = "account" | "email" | "phone";
@@ -51,17 +51,6 @@ interface LocalProfilePageProps {
onLogout: () => void; onLogout: () => void;
} }
const profileWorks = [
{ title: "主图套图生成", desc: "电商主图与场景图自动生成", image: ossAssets.ecommerce.templateCases[0], type: "图像", time: "6/9 18:13" },
{ title: "A+详情页设计", desc: "产品卖点与长图详情版式", image: ossAssets.ecommerce.templateCases[1], type: "图像", time: "6/9 10:11" },
{ title: "短视频广告", desc: "产品展示短视频脚本与画面", image: ossAssets.ecommerce.productSet.hosting, type: "视频", time: "6/9 10:05" },
{ title: "模特图生成", desc: "服饰商品真人上身展示", image: ossAssets.ecommerce.tryOn.tryA, type: "图像", time: "6/9 10:03" },
{ title: "商品场景图", desc: "按平台比例输出营销素材", image: ossAssets.ecommerce.detail.gridA, type: "图像", time: "6/9 10:01" },
{ title: "高度复刻", desc: "参考图结构复刻与商品替换", image: ossAssets.ecommerce.detail.gridB, type: "图像", time: "6/9 09:39" },
{ title: "详情模块", desc: "功能卖点、参数和包装模块", image: ossAssets.ecommerce.detail.gridC, type: "图像", time: "6/8 21:20" },
{ title: "平台素材", desc: "淘宝/天猫投放图批量生成", image: ossAssets.ecommerce.detail.gridD, type: "图像", time: "6/8 18:26" },
];
function LocalAvatar({ session, size = "md" }: { session: WebUserSession; size?: "sm" | "md" | "lg" }) { function LocalAvatar({ session, size = "md" }: { session: WebUserSession; size?: "sm" | "md" | "lg" }) {
const displayName = session.user.displayName || session.user.username || "用户"; const displayName = session.user.displayName || session.user.username || "用户";
const label = displayName.trim().slice(0, 1).toUpperCase() || "用"; const label = displayName.trim().slice(0, 1).toUpperCase() || "用";
@@ -75,9 +64,9 @@ function LocalAvatar({ session, size = "md" }: { session: WebUserSession; size?:
function LocalProfilePage({ session, balance, imageCount, videoCount, onBack, onBugFeedback, onLogout }: LocalProfilePageProps) { function LocalProfilePage({ session, balance, imageCount, videoCount, onBack, onBugFeedback, onLogout }: LocalProfilePageProps) {
const displayName = session.user.displayName || session.user.username || "用户"; const displayName = session.user.displayName || session.user.username || "用户";
const workCount = Math.max(imageCount + videoCount, profileWorks.length); const workCount = Math.max(imageCount + videoCount, 0);
const projectCount = Math.max(1, Math.round(workCount / 18)); const projectCount = 0;
const assetCount = Math.max(1, Math.round(workCount / 20)); const assetCount = 0;
return ( return (
<section className="local-profile-page"> <section className="local-profile-page">
@@ -142,22 +131,15 @@ function LocalProfilePage({ session, balance, imageCount, videoCount, onBack, on
<header> <header>
<div> <div>
<strong></strong> <strong></strong>
<span></span> <span></span>
</div> </div>
<em>{workCount} </em> <em>{workCount} </em>
</header> </header>
<div className="local-profile-work-grid"> <div className="local-profile-work-grid local-profile-work-grid--empty">
{profileWorks.map((work) => ( <div className="local-profile-empty">
<article key={`${work.title}-${work.time}`} className="local-profile-work-card"> <strong></strong>
<img src={work.image} alt="" /> <span></span>
<div>
<span>{work.type}</span>
<strong>{work.title}</strong>
<p>{work.desc}</p>
<em> · {work.time}</em>
</div> </div>
</article>
))}
</div> </div>
</section> </section>
</main> </main>
@@ -184,7 +166,6 @@ function App() {
const [sessionNotice, setSessionNotice] = useState<string | null>(null); const [sessionNotice, setSessionNotice] = useState<string | null>(null);
const [profileMenuOpen, setProfileMenuOpen] = useState(false); const [profileMenuOpen, setProfileMenuOpen] = useState(false);
const [currentPage, setCurrentPage] = useState<"workspace" | "profile">("workspace"); const [currentPage, setCurrentPage] = useState<"workspace" | "profile">("workspace");
const [workspaceKey, setWorkspaceKey] = useState(0);
useEffect(() => { useEffect(() => {
void loadDarkGreenTheme(); void loadDarkGreenTheme();
@@ -339,7 +320,7 @@ function App() {
const balance = Math.max(usage.balanceCents, 0) / 100; const balance = Math.max(usage.balanceCents, 0) / 100;
const displayName = session?.user.displayName || session?.user.username || "用户"; const displayName = session?.user.displayName || session?.user.username || "用户";
const actualWorkCount = Math.max(usage.imageUsed + usage.videoUsed, 0); const actualWorkCount = Math.max(usage.imageUsed + usage.videoUsed, 0);
const shownWorkCount = Math.max(actualWorkCount, profileWorks.length); const shownWorkCount = actualWorkCount;
const avatarMenuStats = useMemo( const avatarMenuStats = useMemo(
() => [ () => [
@@ -360,7 +341,6 @@ function App() {
const handleOpenWorkspace = () => { const handleOpenWorkspace = () => {
setProfileMenuOpen(false); setProfileMenuOpen(false);
setCurrentPage("workspace"); setCurrentPage("workspace");
setWorkspaceKey((k) => k + 1);
}; };
const handleBugFeedback = () => { const handleBugFeedback = () => {
@@ -447,7 +427,8 @@ function App() {
</header> </header>
<main className="ecommerce-standalone__content"> <main className="ecommerce-standalone__content">
{currentPage === "profile" && session ? ( {session ? (
<div className="ecommerce-standalone__page" hidden={currentPage !== "profile"}>
<LocalProfilePage <LocalProfilePage
session={session} session={session}
balance={balance} balance={balance}
@@ -457,7 +438,11 @@ function App() {
onBugFeedback={handleBugFeedback} onBugFeedback={handleBugFeedback}
onLogout={handleLogout} onLogout={handleLogout}
/> />
) : ( </div>
) : null}
{/* 工作台常驻挂载,仅用 hidden 切换。切到个人中心时不卸载,
生成任务、进度动画、已上传图片等本地状态全部保留,切回即继续。 */}
<div className="ecommerce-standalone__page" hidden={Boolean(session) && currentPage === "profile"}>
<ErrorBoundary> <ErrorBoundary>
<Suspense <Suspense
fallback={ fallback={
@@ -468,7 +453,6 @@ function App() {
} }
> >
<EcommercePage <EcommercePage
key={workspaceKey}
projects={[]} projects={[]}
isAuthenticated={Boolean(session)} isAuthenticated={Boolean(session)}
onStartCreate={() => undefined} onStartCreate={() => undefined}
@@ -482,7 +466,7 @@ function App() {
/> />
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
)} </div>
</main> </main>
{authOpen ? ( {authOpen ? (
+44
View File
@@ -38,6 +38,20 @@ export interface SaveGenerationRecordResult {
id: string; id: string;
} }
// 同一 clientRecordId 的保存去重:套图主流程、backgroundTaskRunner、useGenerationTasks
// 三处都可能对同一条终态任务调用 saveGenerationRecordSSE 重复推送 completed 时
// 单个 poller 内也会重复触发。这里做客户端幂等:in-flight 合流 + 成功后短期拦截,
// 避免后端在缺少去重时插入重复记录。
const inFlightSaves = new Map<string, Promise<SaveGenerationRecordResult>>();
const recentlySavedAt = new Map<string, number>();
const SAVE_DEDUPE_WINDOW_MS = 60_000;
function pruneRecentlySaved(now: number): void {
for (const [id, savedAt] of recentlySavedAt) {
if (now - savedAt > SAVE_DEDUPE_WINDOW_MS) recentlySavedAt.delete(id);
}
}
function readPendingRecords(): SaveGenerationRecordInput[] { function readPendingRecords(): SaveGenerationRecordInput[] {
try { try {
const raw = window.localStorage.getItem(PENDING_RECORDS_KEY); const raw = window.localStorage.getItem(PENDING_RECORDS_KEY);
@@ -60,6 +74,36 @@ function writePendingRecord(input: SaveGenerationRecordInput): void {
} }
export async function saveGenerationRecord(input: SaveGenerationRecordInput): Promise<SaveGenerationRecordResult> { export async function saveGenerationRecord(input: SaveGenerationRecordInput): Promise<SaveGenerationRecordResult> {
const now = Date.now();
pruneRecentlySaved(now);
const recordId = input.clientRecordId;
if (recordId) {
const inFlight = inFlightSaves.get(recordId);
if (inFlight) return inFlight;
const savedAt = recentlySavedAt.get(recordId);
if (savedAt !== undefined && now - savedAt <= SAVE_DEDUPE_WINDOW_MS) {
// 终态记录只需落库一次;窗口内的重复调用直接视为已保存。
return { source: "server", id: recordId };
}
}
const promise = saveGenerationRecordInternal(input);
if (recordId) {
inFlightSaves.set(recordId, promise);
void promise
.then((result) => {
if (result.source === "server") recentlySavedAt.set(recordId, Date.now());
})
.catch(() => undefined)
.finally(() => {
inFlightSaves.delete(recordId);
});
}
return promise;
}
async function saveGenerationRecordInternal(input: SaveGenerationRecordInput): Promise<SaveGenerationRecordResult> {
try { try {
const response = await serverRequest<{ id?: string | number }>("ai/generation-records", { const response = await serverRequest<{ id?: string | number }>("ai/generation-records", {
method: "POST", method: "POST",
+164 -20
View File
@@ -156,6 +156,16 @@ const ecommerceInspirationRows = [
}, },
] as const; ] as const;
// 把灵感卡片的标题 + 卖点要点合成一段可直接填入指令栏的提示词。
const buildInspirationPrompt = (title: string, meta: string): string => {
const points = meta
.split(/[·、,]/)
.map((part) => part.trim())
.filter(Boolean);
const base = title.trim();
return points.length ? `${base}。风格要点:${points.join("、")}` : `${base}`;
};
const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const normalizeHexColor = (value: string) => { const normalizeHexColor = (value: string) => {
@@ -1385,6 +1395,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [productSetRequirement, setProductSetRequirement] = useState(""); const [productSetRequirement, setProductSetRequirement] = useState("");
const [productSetOutput, setProductSetOutput] = useState<ProductSetOutputKey>(defaultProductSetOutput); const [productSetOutput, setProductSetOutput] = useState<ProductSetOutputKey>(defaultProductSetOutput);
const [productSetStatus, setProductSetStatus] = useState<ProductSetStatus>("idle"); const [productSetStatus, setProductSetStatus] = useState<ProductSetStatus>("idle");
// 套图/图像生成的真实进度(0-100):多张串行生成时按"已完成张数 + 当前张子进度"推进,
// 替代进度条原先写死 50 导致卡在 75% 的假进度。
const [generationProgress, setGenerationProgress] = useState(0);
const [productSetResultImages, setProductSetResultImages] = useState<string[]>([]); const [productSetResultImages, setProductSetResultImages] = useState<string[]>([]);
const [isSetUploadDragging, setIsSetUploadDragging] = useState(false); const [isSetUploadDragging, setIsSetUploadDragging] = useState(false);
const [selectedProductSetPreview, setSelectedProductSetPreview] = useState<ProductSetPreviewSelection | null>(null); const [selectedProductSetPreview, setSelectedProductSetPreview] = useState<ProductSetPreviewSelection | null>(null);
@@ -1411,9 +1424,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [watermarkResultUrl, setWatermarkResultUrl] = useState<string | null>(null); const [watermarkResultUrl, setWatermarkResultUrl] = useState<string | null>(null);
const [watermarkProgress, setWatermarkProgress] = useState(0); const [watermarkProgress, setWatermarkProgress] = useState(0);
const [translateImage, setTranslateImage] = useState<{ src: string; name: string; format: string } | null>(null); const [translateImage, setTranslateImage] = useState<{ src: string; name: string; format: string } | null>(null);
const [translateStatus, setTranslateStatus] = useState<"idle" | "processing" | "done">("idle"); const [translateStatus, setTranslateStatus] = useState<"idle" | "processing" | "done" | "failed">("idle");
const [isTranslateDragging, setIsTranslateDragging] = useState(false); const [isTranslateDragging, setIsTranslateDragging] = useState(false);
const [translateLanguage, setTranslateLanguage] = useState("zh"); const [translateLanguage, setTranslateLanguage] = useState("zh");
const [translateResultUrl, setTranslateResultUrl] = useState<string | null>(null);
const [imageWorkbenchImage, setImageWorkbenchImage] = useState<{ src: string; name: string; format: string } | null>(null); const [imageWorkbenchImage, setImageWorkbenchImage] = useState<{ src: string; name: string; format: string } | null>(null);
const [imageWorkbenchPrompt, setImageWorkbenchPrompt] = useState(""); const [imageWorkbenchPrompt, setImageWorkbenchPrompt] = useState("");
const [imageWorkbenchBrushSize, setImageWorkbenchBrushSize] = useState(50); const [imageWorkbenchBrushSize, setImageWorkbenchBrushSize] = useState(50);
@@ -1437,8 +1451,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [visibleComposerMenu, setVisibleComposerMenu] = useState<ComposerMenuKey | null>(null); const [visibleComposerMenu, setVisibleComposerMenu] = useState<ComposerMenuKey | null>(null);
const [isComposerMenuClosing, setIsComposerMenuClosing] = useState(false); const [isComposerMenuClosing, setIsComposerMenuClosing] = useState(false);
const [composerPopoverLeft, setComposerPopoverLeft] = useState(0); const [composerPopoverLeft, setComposerPopoverLeft] = useState(0);
const [composerPopoverTop, setComposerPopoverTop] = useState(0);
const [isCommandHistoryCollapsed, setIsCommandHistoryCollapsed] = useState(true); const [isCommandHistoryCollapsed, setIsCommandHistoryCollapsed] = useState(true);
const [inspirationPreview, setInspirationPreview] = useState<{ mediaUrl: string; mediaType: "image" | "video" } | null>(null); const [inspirationPreview, setInspirationPreview] = useState<{ mediaUrl: string; mediaType: "image" | "video"; prompt: string } | null>(null);
const [isQuickPanelCollapsed, setIsQuickPanelCollapsed] = useState(false); const [isQuickPanelCollapsed, setIsQuickPanelCollapsed] = useState(false);
const [openCloneModelSelect, setOpenCloneModelSelect] = useState<CloneModelSelectKey | null>(null); const [openCloneModelSelect, setOpenCloneModelSelect] = useState<CloneModelSelectKey | null>(null);
const [cloneModelSelectDropUp, setCloneModelSelectDropUp] = useState(false); const [cloneModelSelectDropUp, setCloneModelSelectDropUp] = useState(false);
@@ -2082,6 +2097,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
stopWatermarkProgress(); stopWatermarkProgress();
setWatermarkProgress(100); setWatermarkProgress(100);
toast.success("去水印处理完成"); toast.success("去水印处理完成");
void saveUnifiedEcommerceGenerationRecord({
clientRecordId: crypto.randomUUID(),
title: `去水印 ${watermarkImage.name || ""}`.trim(),
mode: "watermark",
taskIds: [taskId],
sourceImages: [{ url: imageUrl, label: watermarkImage.name || "watermark-source" }],
results: [{ url: persistedUrl, label: "去水印结果", mediaType: "image", taskId }],
createdAt: new Date().toISOString(),
});
} else { } else {
setWatermarkStatus("failed"); setWatermarkStatus("failed");
stopWatermarkProgress(); stopWatermarkProgress();
@@ -2128,6 +2152,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
} }
setActiveQuickTool(null); setActiveQuickTool(null);
setTranslateStatus("idle"); setTranslateStatus("idle");
setTranslateResultUrl(null);
setTranslateImage((current) => { setTranslateImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src); if (current?.src) URL.revokeObjectURL(current.src);
return null; return null;
@@ -2145,6 +2170,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
return nextImage; return nextImage;
}); });
setTranslateStatus("idle"); setTranslateStatus("idle");
setTranslateResultUrl(null);
setActiveQuickTool("translate"); setActiveQuickTool("translate");
}; };
@@ -2154,6 +2180,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
translateProcessTimeoutRef.current = null; translateProcessTimeoutRef.current = null;
} }
setTranslateStatus("idle"); setTranslateStatus("idle");
setTranslateResultUrl(null);
setTranslateImage((current) => { setTranslateImage((current) => {
if (current?.src) URL.revokeObjectURL(current.src); if (current?.src) URL.revokeObjectURL(current.src);
return null; return null;
@@ -2182,28 +2209,76 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
return nextImage; return nextImage;
}); });
setTranslateStatus("idle"); setTranslateStatus("idle");
setTranslateResultUrl(null);
toast.success("图片已导入"); toast.success("图片已导入");
}; };
const handleTranslateGenerate = () => { const handleTranslateGenerate = async () => {
if (!translateImage || translateStatus === "processing") return; if (!translateImage || translateStatus === "processing") return;
if (translateProcessTimeoutRef.current !== null) window.clearTimeout(translateProcessTimeoutRef.current); const targetLabel = translateLanguageOptions.find((option) => option.value === translateLanguage)?.label || "中文";
setTranslateStatus("processing"); setTranslateStatus("processing");
translateProcessTimeoutRef.current = window.setTimeout(() => { setTranslateResultUrl(null);
translateProcessTimeoutRef.current = null;
try {
const sourceBlob = await fetch(translateImage.src).then((res) => res.blob());
const sourceMime = normalizeEcommerceImageMime(sourceBlob.type || "image/png");
const { url: imageUrl } = await aiGenerationClient.uploadAssetBinary(sourceBlob, {
name: `translate-source-${Date.now()}.png`,
mimeType: sourceMime,
scope: ecommerceOssScopes.productSource,
});
const prompt = `将图片中的所有文字翻译成${targetLabel},保持原有的排版、字体风格、位置和整体设计不变,只替换文字内容。`;
const { taskId } = await aiGenerationClient.createImageEditTask({
imageUrl,
function: "description_edit",
prompt,
});
const resultUrl = await waitForTask(taskId, {
kind: "image",
abortRef: { current: false },
onProgress: () => {},
});
if (resultUrl) {
const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult("translate"), "ecommerce-translate");
setTranslateResultUrl(persistedUrl);
setTranslateStatus("done"); setTranslateStatus("done");
toast.success("图片翻译完成"); toast.success("图片翻译完成");
}, 900); void saveUnifiedEcommerceGenerationRecord({
clientRecordId: crypto.randomUUID(),
title: `图片翻译(${targetLabel} ${translateImage.name || ""}`.trim(),
mode: "translate",
prompt,
taskIds: [taskId],
sourceImages: [{ url: imageUrl, label: translateImage.name || "translate-source" }],
results: [{ url: persistedUrl, label: "翻译结果", mediaType: "image", taskId }],
config: { targetLanguage: translateLanguage },
createdAt: new Date().toISOString(),
});
} else {
setTranslateStatus("failed");
toast.error("翻译未返回结果");
}
} catch (err) {
setTranslateStatus("failed");
if (err instanceof ServerRequestError && err.status === 402) {
toast.error("余额不足,请充值后继续");
} else {
toast.error(err instanceof Error ? err.message : "图片翻译失败");
}
}
}; };
const handleTranslateDownload = () => { const handleTranslateDownload = () => {
if (!translateImage || translateStatus !== "done") { if (!translateResultUrl || translateStatus !== "done") {
toast.info("请先完成图片翻译"); toast.info("请先完成图片翻译");
return; return;
} }
const link = document.createElement("a"); const link = document.createElement("a");
const safeName = (translateImage.name || "translate-result").replace(/\.[^.]+$/, "").replace(/[\\/:*?"<>|]+/g, "-"); const safeName = (translateImage?.name || "translate-result").replace(/\.[^.]+$/, "").replace(/[\\/:*?"<>|]+/g, "-");
link.href = translateImage.src; link.href = translateResultUrl;
link.download = `${safeName || "translate-result"}-翻译.png`; link.download = `${safeName || "translate-result"}-翻译.png`;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
@@ -2402,6 +2477,17 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
stopWorkbenchProgress(); stopWorkbenchProgress();
setImageWorkbenchProgress(100); setImageWorkbenchProgress(100);
toast.success("局部重绘已完成"); toast.success("局部重绘已完成");
void saveUnifiedEcommerceGenerationRecord({
clientRecordId: crypto.randomUUID(),
title: imageWorkbenchPrompt.trim() || `图片修改 ${imageWorkbenchImage.name || ""}`.trim(),
mode: "inpaint",
prompt: imageWorkbenchPrompt || undefined,
taskIds: [taskId],
sourceImages: [{ url: imageUrl, label: imageWorkbenchImage.name || "inpaint-source" }],
results: [{ url: persistedUrl, label: "局部重绘结果", mediaType: "image", taskId }],
config: { ratio: imageWorkbenchRatio },
createdAt: new Date().toISOString(),
});
} else { } else {
setImageWorkbenchStatus("failed"); setImageWorkbenchStatus("failed");
stopWorkbenchProgress(); stopWorkbenchProgress();
@@ -3557,6 +3643,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const generatedUrls: string[] = []; const generatedUrls: string[] = [];
const stamp = Date.now(); const stamp = Date.now();
const totalCount = Math.max(1, cloneSetCountKeys.reduce((sum, key) => sum + counts[key], 0));
let completedCount = 0;
setGenerationProgress(0);
for (const countKey of cloneSetCountKeys) { for (const countKey of cloneSetCountKeys) {
if (imageAbortRef.current.current) break; if (imageAbortRef.current.current) break;
@@ -3580,8 +3669,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
let resultUrl: string | null = null; let resultUrl: string | null = null;
try { try {
resultUrl = await waitForTask(taskId, { resultUrl = await waitForTask(taskId, {
kind: "image",
abortRef: imageAbortRef.current, abortRef: imageAbortRef.current,
onProgress: () => {}, onProgress: (event) => {
// 整体进度 = (已完成张数 + 当前张子进度) / 总张数。
const sub = Math.max(0, Math.min(100, Number(event.progress) || 0));
const overall = ((completedCount + sub / 100) / totalCount) * 100;
setGenerationProgress(Math.round(Math.min(99, overall)));
},
}); });
} finally { } finally {
untrackEcommerceTask(taskId); untrackEcommerceTask(taskId);
@@ -3597,6 +3692,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
generatedUrls.push(""); generatedUrls.push("");
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" }); imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
} }
completedCount += 1;
setGenerationProgress(Math.round(Math.min(99, (completedCount / totalCount) * 100)));
} }
} }
@@ -3648,6 +3745,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket, tryOnOptions); const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket, tryOnOptions);
const stamp = Date.now(); const stamp = Date.now();
setGenerationProgress(0);
const { taskId } = await aiGenerationClient.createImageTask({ const { taskId } = await aiGenerationClient.createImageTask({
prompt, prompt,
@@ -3663,8 +3761,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
let resultUrl: string | null = null; let resultUrl: string | null = null;
try { try {
resultUrl = await waitForTask(taskId, { resultUrl = await waitForTask(taskId, {
kind: "image",
abortRef: imageAbortRef.current, abortRef: imageAbortRef.current,
onProgress: () => {}, onProgress: (event) => {
const sub = Math.max(0, Math.min(100, Number(event.progress) || 0));
setGenerationProgress(Math.round(Math.min(99, sub)));
},
}); });
} finally { } finally {
untrackEcommerceTask(taskId); untrackEcommerceTask(taskId);
@@ -4501,7 +4603,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<section className="product-set-empty-preview" aria-live="polite"> <section className="product-set-empty-preview" aria-live="polite">
{productSetStatus === "generating" ? <LoadingOutlined /> : <FileImageOutlined />} {productSetStatus === "generating" ? <LoadingOutlined /> : <FileImageOutlined />}
<strong>{productSetStatus === "generating" ? "正在生成" : "等待生成"}</strong> <strong>{productSetStatus === "generating" ? "正在生成" : "等待生成"}</strong>
{productSetStatus === "generating" ? <EcommerceProgressBar status="generating" onCancel={handleCancelGenerate} label="商品套图" /> : null} {productSetStatus === "generating" ? <EcommerceProgressBar status="generating" progress={generationProgress} onCancel={handleCancelGenerate} label="商品套图" /> : null}
<span>{productSetStatus === "generating" ? "AI 正在整理主图、场景、细节与卖点图。" : "上传商品原图并填写信息后,AI 将为您生成专业的电商商品图。"}</span> <span>{productSetStatus === "generating" ? "AI 正在整理主图、场景、细节与卖点图。" : "上传商品原图并填写信息后,AI 将为您生成专业的电商商品图。"}</span>
</section> </section>
)} )}
@@ -4554,7 +4656,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
language: item, language: item,
countries: marketLanguageOptions.filter((option) => option.languages.includes(item)).map((option) => option.country), countries: marketLanguageOptions.filter((option) => option.languages.includes(item)).map((option) => option.country),
})); }));
const composerPopoverStyle: CSSProperties = { left: composerPopoverLeft }; const composerPopoverStyle = {
"--composer-popover-left": `${composerPopoverLeft}px`,
"--composer-popover-top": `${composerPopoverTop}px`,
} as CSSProperties;
const menuToRender = composerMenu ?? visibleComposerMenu; const menuToRender = composerMenu ?? visibleComposerMenu;
if (!menuToRender) return null; if (!menuToRender) return null;
const popoverClosingClass = !composerMenu && isComposerMenuClosing ? " is-closing" : ""; const popoverClosingClass = !composerMenu && isComposerMenuClosing ? " is-closing" : "";
@@ -4735,7 +4840,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const toggleComposerMenu = (menuKey: ComposerMenuKey, event: ReactMouseEvent<HTMLButtonElement>) => { const toggleComposerMenu = (menuKey: ComposerMenuKey, event: ReactMouseEvent<HTMLButtonElement>) => {
const composerRect = event.currentTarget.closest(".clone-ai-input-wrapper.ecom-command-composer")?.getBoundingClientRect(); const composerRect = event.currentTarget.closest(".clone-ai-input-wrapper.ecom-command-composer")?.getBoundingClientRect();
const buttonRect = event.currentTarget.getBoundingClientRect(); const buttonRect = event.currentTarget.getBoundingClientRect();
setComposerPopoverLeft(Math.max(0, buttonRect.left - (composerRect?.left ?? 0))); const composerLeft = composerRect?.left ?? buttonRect.left;
const composerTop = composerRect?.top ?? buttonRect.top;
setComposerPopoverLeft(Math.max(0, buttonRect.left - composerLeft));
setComposerPopoverTop(Math.max(0, buttonRect.bottom - composerTop + 8));
setComposerMenu((menu) => (menu === menuKey ? null : menuKey)); setComposerMenu((menu) => (menu === menuKey ? null : menuKey));
}; };
@@ -4772,6 +4880,24 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
strip.scrollBy({ left: direction * Math.max(280, strip.clientWidth * 0.78), behavior: "smooth" }); strip.scrollBy({ left: direction * Math.max(280, strip.clientWidth * 0.78), behavior: "smooth" });
}; };
const applyInspirationPrompt = (prompt: string) => {
const nextValue = prompt.slice(0, 500);
// 回到主指令栏(关闭可能打开的快捷工具页),把提示词填入并聚焦。
setActiveQuickTool(null);
setRequirement(nextValue);
syncRequirementMentionQuery(nextValue, nextValue.length);
setInspirationPreview(null);
requestAnimationFrame(() => {
const textarea = requirementTextareaRef.current;
if (textarea) {
textarea.focus();
textarea.setSelectionRange(nextValue.length, nextValue.length);
textarea.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
toast.success("提示词已填入指令栏");
};
const inspirationPreviewOverlay = const inspirationPreviewOverlay =
inspirationPreview && typeof document !== "undefined" inspirationPreview && typeof document !== "undefined"
? createPortal( ? createPortal(
@@ -4790,6 +4916,18 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
) : ( ) : (
<img src={inspirationPreview.mediaUrl} alt="" className="ecom-inspiration-preview__media" /> <img src={inspirationPreview.mediaUrl} alt="" className="ecom-inspiration-preview__media" />
)} )}
{inspirationPreview.prompt ? (
<div className="ecom-inspiration-preview__actions">
<button
type="button"
className="ecom-inspiration-preview__use-prompt"
onClick={() => applyInspirationPrompt(inspirationPreview.prompt)}
>
<EditOutlined />
<span>使</span>
</button>
</div>
) : null}
</div> </div>
</div>, </div>,
document.body, document.body,
@@ -4929,7 +5067,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<> <>
<LoadingOutlined style={{ fontSize: 28 }} /> <LoadingOutlined style={{ fontSize: 28 }} />
<strong></strong> <strong></strong>
<EcommerceProgressBar status="generating" onCancel={handleCancelGenerate} label={`${selectedCloneOutput.label}生成`} /> <EcommerceProgressBar status="generating" progress={generationProgress} onCancel={handleCancelGenerate} label={`${selectedCloneOutput.label}生成`} />
<span>AI {platform} / {market} {selectedCloneOutput.label}</span> <span>AI {platform} / {market} {selectedCloneOutput.label}</span>
</> </>
) : status === "failed" ? ( ) : status === "failed" ? (
@@ -5028,7 +5166,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<section className="clone-ai-empty-state" aria-live="polite"> <section className="clone-ai-empty-state" aria-live="polite">
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <FrownOutlined /> : <FileImageOutlined />} {status === "generating" ? <LoadingOutlined /> : status === "failed" ? <FrownOutlined /> : <FileImageOutlined />}
<strong>{status === "generating" ? "正在生成" : status === "failed" ? "生成失败" : "等待生成"}</strong> <strong>{status === "generating" ? "正在生成" : status === "failed" ? "生成失败" : "等待生成"}</strong>
{status === "generating" ? <EcommerceProgressBar status="generating" onCancel={handleCancelGenerate} label={`${selectedCloneOutput.label}生成`} /> : null} {status === "generating" ? <EcommerceProgressBar status="generating" progress={generationProgress} onCancel={handleCancelGenerate} label={`${selectedCloneOutput.label}生成`} /> : null}
<span> <span>
{status === "generating" {status === "generating"
? "AI 正在为 " + platform + " / " + market + " 整理" + selectedCloneOutput.label + "。" ? "AI 正在为 " + platform + " / " + market + " 整理" + selectedCloneOutput.label + "。"
@@ -5223,7 +5361,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</div> </div>
<div className="ecom-inspiration-strip" tabIndex={0}> <div className="ecom-inspiration-strip" tabIndex={0}>
{row.cards.map((card, index) => ( {row.cards.map((card, index) => (
<article key={card.title} className="ecom-inspiration-card" onClick={() => setInspirationPreview({ mediaUrl: card.mediaUrl, mediaType: card.mediaType })}> <article key={card.title} className="ecom-inspiration-card" onClick={() => setInspirationPreview({ mediaUrl: card.mediaUrl, mediaType: card.mediaType, prompt: buildInspirationPrompt(card.title, card.meta) })}>
<div className="ecom-inspiration-card__visual" aria-hidden="true"> <div className="ecom-inspiration-card__visual" aria-hidden="true">
{card.mediaType === "video" ? ( {card.mediaType === "video" ? (
<video src={card.mediaUrl} muted playsInline loop autoPlay preload="metadata" /> <video src={card.mediaUrl} muted playsInline loop autoPlay preload="metadata" />
@@ -6135,13 +6273,19 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<strong></strong> <strong></strong>
<em>AI </em> <em>AI </em>
</div> </div>
) : translateStatus === "done" ? ( ) : translateStatus === "done" && translateResultUrl ? (
<> <>
<img src={translateImage.src} alt="翻译结果" /> <img src={translateResultUrl} alt="翻译结果" />
<button type="button" className="ecom-watermark-zoom" aria-label="查看大图"> <button type="button" className="ecom-watermark-zoom" aria-label="查看大图">
<QuestionCircleOutlined /> <QuestionCircleOutlined />
</button> </button>
</> </>
) : translateStatus === "failed" ? (
<div className="ecom-watermark-empty">
<GlobalOutlined />
<strong></strong>
<em></em>
</div>
) : ( ) : (
<div className="ecom-watermark-empty"> <div className="ecom-watermark-empty">
<GlobalOutlined /> <GlobalOutlined />
@@ -1,10 +1,11 @@
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress"; import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
import type { ReactNode } from "react";
interface EcommerceProgressBarProps { interface EcommerceProgressBarProps {
status: "idle" | "generating" | "done" | "failed" | string; status: "idle" | "generating" | "done" | "failed" | string;
label?: string; label?: string;
onCancel?: () => void; onCancel?: () => void;
/** 0-100 真实进度。传入时进度条按真实值推进;省略时按状态做平滑蠕动。 */
progress?: number;
} }
function mapStatus(status: string): "running" | "completed" | "failed" { function mapStatus(status: string): "running" | "completed" | "failed" {
@@ -14,9 +15,13 @@ function mapStatus(status: string): "running" | "completed" | "failed" {
return "running"; return "running";
} }
export function EcommerceProgressBar({ status, label, onCancel }: EcommerceProgressBarProps) { export function EcommerceProgressBar({ status, label, onCancel, progress }: EcommerceProgressBarProps) {
const progress = mapStatus(status) === "running" ? 50 : 100; const mapped = mapStatus(status);
const smoothed = useSmoothedProgress(progress, mapStatus(status)); // running 时目标取「真实进度」与兜底值 88 的较大者:有真实进度则跟随推进,
// 后端不推中间进度时也由平滑器持续蠕动到高位,不再卡死在 75%。
const realProgress = typeof progress === "number" ? Math.max(0, Math.min(100, progress)) : 0;
const target = mapped === "running" ? Math.max(realProgress, 88) : 100;
const smoothed = useSmoothedProgress(target, mapped);
if (status === "idle") return null; if (status === "idle") return null;
-9
View File
@@ -2,15 +2,6 @@ import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import "./styles/index.css"; import "./styles/index.css";
import App from "./App"; import App from "./App";
import { reportError } from "./utils/errorReporting";
window.addEventListener("unhandledrejection", (event) => {
reportError(event.reason, "rejection");
});
window.addEventListener("error", (event) => {
if (event.error) reportError(event.error, "unhandled");
});
const root = document.getElementById("root"); const root = document.getElementById("root");
+20 -15
View File
@@ -24,17 +24,33 @@ interface PersistedQueueSnapshot {
savedAt: number; savedAt: number;
} }
const STORAGE_KEY = "omniai:generation-queue"; const STORAGE_KEY_PREFIX = "omniai:generation-queue";
const MAX_ITEMS = 80; const MAX_ITEMS = 80;
const STALE_MS = 2 * 60 * 60 * 1000; // 2 hours const STALE_MS = 2 * 60 * 60 * 1000; // 2 hours
function hashUserId(): string {
try {
const raw = localStorage.getItem("omniai-web-session");
if (!raw) return "anon";
const parsed = JSON.parse(raw) as { user?: { id?: number | string } };
return String(parsed?.user?.id || "anon");
} catch {
return "anon";
}
}
// 队列按用户分桶持久化:不同账号读写不同 key,避免登出再登他人账号时读到上一个用户的队列。
function getStorageKey(): string {
return `${STORAGE_KEY_PREFIX}:${hashUserId()}`;
}
function loadPersistedQueue(): GenerationQueueItem[] { function loadPersistedQueue(): GenerationQueueItem[] {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(getStorageKey());
if (!raw) return []; if (!raw) return [];
const snapshot = JSON.parse(raw) as PersistedQueueSnapshot; const snapshot = JSON.parse(raw) as PersistedQueueSnapshot;
if (Date.now() - (snapshot.savedAt || 0) > STALE_MS) { if (Date.now() - (snapshot.savedAt || 0) > STALE_MS) {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(getStorageKey());
return []; return [];
} }
return snapshot.items.filter( return snapshot.items.filter(
@@ -48,7 +64,7 @@ function loadPersistedQueue(): GenerationQueueItem[] {
function persistQueue(items: GenerationQueueItem[]): void { function persistQueue(items: GenerationQueueItem[]): void {
try { try {
const snapshot: PersistedQueueSnapshot = { version: 1, items, savedAt: Date.now() }; const snapshot: PersistedQueueSnapshot = { version: 1, items, savedAt: Date.now() };
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); localStorage.setItem(getStorageKey(), JSON.stringify(snapshot));
} catch { /* quota exceeded */ } } catch { /* quota exceeded */ }
} }
@@ -63,17 +79,6 @@ interface GenerationStoreState {
clearTerminal: () => void; clearTerminal: () => void;
} }
function hashUserId(): string {
try {
const raw = localStorage.getItem("omniai-web-session");
if (!raw) return "anon";
const parsed = JSON.parse(raw) as { user?: { id?: number | string } };
return String(parsed?.user?.id || "anon");
} catch {
return "anon";
}
}
const initialQueue = loadPersistedQueue(); const initialQueue = loadPersistedQueue();
export const useGenerationStore = create<GenerationStoreState>((set, get) => ({ export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
+147
View File
@@ -69,6 +69,17 @@
padding-top: 64px; padding-top: 64px;
} }
/* 工作台与个人中心常驻同层 hidden 切换以保活生成任务状态
wrapper 需要撑满内容区让内部 .product-clone-page/.local-profile-page height:100% 生效 */
.ecommerce-standalone__page {
height: 100%;
min-height: 0;
}
.ecommerce-standalone__page[hidden] {
display: none !important;
}
.ecommerce-standalone__content > .error-boundary, .ecommerce-standalone__content > .error-boundary,
.ecommerce-standalone__content .product-clone-page { .ecommerce-standalone__content .product-clone-page {
height: 100%; height: 100%;
@@ -1346,6 +1357,34 @@
font-weight: 500; font-weight: 500;
} }
.local-profile-work-grid--empty {
display: block;
}
.local-profile-empty {
display: grid;
min-height: 220px;
place-items: center;
gap: 8px;
padding: 36px 20px;
border: 1px dashed rgba(30, 189, 219, 0.22);
border-radius: 18px;
color: #6c7d88;
text-align: center;
background: #f8fbfc;
}
.local-profile-empty strong {
color: #10202c;
font-size: 15px;
}
.local-profile-empty span {
max-width: 360px;
font-size: 13px;
line-height: 1.6;
}
@media (max-width: 980px) { @media (max-width: 980px) {
.local-profile-page__body { .local-profile-page__body {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
@@ -12354,6 +12393,40 @@ body .ecom-inspiration-preview__close {
display: none !important; display: none !important;
} }
/* 灵感预览:右下角"使用此提示词"动作条,避开视频底部控制条。 */
body .ecom-inspiration-preview__actions {
position: absolute !important;
right: 16px !important;
bottom: 16px !important;
z-index: 2 !important;
display: flex !important;
gap: 10px !important;
}
body .ecom-inspiration-preview__use-prompt {
display: inline-flex !important;
align-items: center !important;
gap: 8px !important;
padding: 10px 20px !important;
border: 1px solid rgba(255, 255, 255, 0.28) !important;
border-radius: 999px !important;
background: rgba(16, 32, 44, 0.72) !important;
backdrop-filter: blur(8px) !important;
-webkit-backdrop-filter: blur(8px) !important;
color: #ffffff !important;
font-size: 14px !important;
font-weight: 600 !important;
cursor: pointer !important;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28) !important;
transition: background 160ms ease, transform 160ms ease, border-color 160ms ease !important;
}
body .ecom-inspiration-preview__use-prompt:hover {
border-color: rgba(30, 189, 219, 0.6) !important;
background: rgba(30, 189, 219, 0.92) !important;
transform: translateY(-1px) !important;
}
@media (max-width: 760px) { @media (max-width: 760px) {
body .ecom-inspiration-preview { body .ecom-inspiration-preview {
padding: 14px !important; padding: 14px !important;
@@ -13934,3 +14007,77 @@ html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[d
min-height: 36px !important; min-height: 36px !important;
max-height: 36px !important; max-height: 36px !important;
} }
/* Composer menu anchors: place option popovers under the clicked control, not under the whole composer. */
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover {
position: absolute !important;
inset: var(--composer-popover-top, 48px) auto auto var(--composer-popover-left, 0px) !important;
right: auto !important;
bottom: auto !important;
margin: 0 !important;
transform: none !important;
translate: none !important;
z-index: 160 !important;
}
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover--platform {
width: min(360px, calc(100% - var(--composer-popover-left, 0px))) !important;
max-width: min(360px, calc(100% - var(--composer-popover-left, 0px))) !important;
}
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover--languages,
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover--ratio-picker {
width: min(420px, calc(100% - var(--composer-popover-left, 0px))) !important;
max-width: min(420px, calc(100% - var(--composer-popover-left, 0px))) !important;
}
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover--settings {
width: min(520px, calc(100% - var(--composer-popover-left, 0px))) !important;
max-width: min(520px, calc(100% - var(--composer-popover-left, 0px))) !important;
}
/* Uploaded assets stay as compact attachments inside the composer hierarchy. */
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer:has(.ecom-command-asset-popover) {
min-height: clamp(224px, 18vh, 250px) !important;
}
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-asset-popover,
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-composer-wrap:not(.has-generated.is-compact) .clone-ai-input-wrapper.ecom-command-composer:has(.ecom-command-asset-popover) .ecom-command-asset-popover {
position: static !important;
grid-column: 1 !important;
display: inline-flex !important;
align-items: center !important;
justify-content: flex-start !important;
justify-self: start !important;
gap: 8px !important;
width: auto !important;
max-width: min(100%, 420px) !important;
min-height: 48px !important;
max-height: 52px !important;
padding: 0 !important;
overflow-x: auto !important;
overflow-y: visible !important;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
transform: none !important;
}
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-asset-thumb,
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-composer-wrap:not(.has-generated.is-compact) .clone-ai-input-wrapper.ecom-command-composer:has(.ecom-command-asset-popover) .ecom-command-asset-thumb {
flex: 0 0 48px !important;
width: 48px !important;
height: 48px !important;
border-radius: 12px !important;
}
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-asset-add,
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-composer-wrap:not(.has-generated.is-compact) .clone-ai-input-wrapper.ecom-command-composer:has(.ecom-command-asset-popover) .ecom-command-asset-add {
flex: 0 0 34px !important;
width: 34px !important;
height: 34px !important;
min-height: 34px !important;
margin: 0 !important;
font-size: 22px !important;
}
+107
View File
@@ -12093,3 +12093,110 @@ html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[d
grid-row: auto !important; grid-row: auto !important;
} }
} }
/* Composer menu anchors: place option popovers under the clicked control, not under the whole composer. */
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover {
position: absolute !important;
inset: var(--composer-popover-top, 48px) auto auto var(--composer-popover-left, 0px) !important;
right: auto !important;
bottom: auto !important;
margin: 0 !important;
transform: none !important;
translate: none !important;
z-index: 160 !important;
}
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover--platform {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
}
/* 平台弹窗宽度仅桌面/平板固定;≤640px 由移动端断点的全宽规则接管。 */
@media (min-width: 641px) {
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover--platform {
width: min(460px, calc(100% - 24px)) !important;
max-width: min(460px, calc(100% - 24px)) !important;
}
}
/* 平台选项:logo + 名称横排,名称过长省略,避免在窄网格里溢出弹窗。 */
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-popover--platform button {
display: flex !important;
align-items: center !important;
justify-content: flex-start !important;
gap: 8px !important;
min-width: 0 !important;
text-align: left !important;
}
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-popover--platform button .ecom-platform-name {
min-width: 0 !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
@media (min-width: 641px) {
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover--languages,
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover--ratio-picker {
width: max-content !important;
min-width: 200px !important;
max-width: min(420px, calc(100% - var(--composer-popover-left, 0px))) !important;
}
}
/* 宽设置面板固定宽度并靠右对齐 composer避免从靠右的"设置"按钮左对齐展开时顶出右边缘被裁
仅桌面/平板生效640px 由移动端断点的全宽规则接管 */
@media (min-width: 641px) {
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer > .ecom-command-popover.ecom-command-popover--settings {
width: min(520px, calc(100% - 24px)) !important;
max-width: min(520px, calc(100% - 24px)) !important;
left: auto !important;
inset: var(--composer-popover-top, 48px) 12px auto auto !important;
}
}
/* Uploaded assets stay as compact attachments inside the composer hierarchy. */
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .clone-ai-input-wrapper.ecom-command-composer:has(.ecom-command-asset-popover) {
min-height: 0 !important;
}
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-asset-popover,
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-composer-wrap:not(.has-generated.is-compact) .clone-ai-input-wrapper.ecom-command-composer:has(.ecom-command-asset-popover) .ecom-command-asset-popover {
position: static !important;
grid-column: 1 !important;
display: flex !important;
flex-wrap: wrap !important;
align-items: center !important;
justify-content: flex-start !important;
justify-self: start !important;
gap: 10px !important;
width: auto !important;
max-width: 100% !important;
min-height: 0 !important;
max-height: none !important;
padding: 2px 2px 0 !important;
overflow: visible !important;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
transform: none !important;
}
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-asset-thumb,
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-composer-wrap:not(.has-generated.is-compact) .clone-ai-input-wrapper.ecom-command-composer:has(.ecom-command-asset-popover) .ecom-command-asset-thumb {
flex: 0 0 64px !important;
width: 64px !important;
height: 64px !important;
border-radius: 14px !important;
}
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-asset-add,
html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[data-tool="clone"][data-tool="clone"] .ecom-command-composer-wrap:not(.has-generated.is-compact) .clone-ai-input-wrapper.ecom-command-composer:has(.ecom-command-asset-popover) .ecom-command-asset-add {
flex: 0 0 44px !important;
width: 44px !important;
height: 64px !important;
min-height: 44px !important;
margin: 0 !important;
font-size: 24px !important;
}
+9 -5
View File
@@ -2,8 +2,14 @@ import react from "@vitejs/plugin-react";
import { compression } from "vite-plugin-compression2"; import { compression } from "vite-plugin-compression2";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
export default defineConfig(() => { export default defineConfig(({ command }) => {
const devApiTarget = process.env.OMNIAI_DEV_API_TARGET?.trim(); // dev 模式下默认把 /api 代理到线上电商后端,本地 `npm run dev` 即可直接登录/生成。
// 想连本地或 SSH 隧道的后端时,用环境变量覆盖:
// $env:OMNIAI_DEV_API_TARGET="http://127.0.0.1:3601"; npm run dev
// 仅 dev 代理用途,不会打进生产构建产物。
const devApiTarget =
process.env.OMNIAI_DEV_API_TARGET?.trim() ||
(command === "serve" ? "https://omniai.com.cn" : "");
const apiProxy = devApiTarget const apiProxy = devApiTarget
? { ? {
"/api": { "/api": {
@@ -27,9 +33,7 @@ export default defineConfig(() => {
port: 4174, port: 4174,
host: "127.0.0.1", host: "127.0.0.1",
}, },
esbuild: { ...(command === "build" ? { esbuild: { drop: ["console", "debugger"] } } : {}),
drop: ["console", "debugger"],
},
build: { build: {
sourcemap: false, sourcemap: false,
rollupOptions: { rollupOptions: {