diff --git a/index.html b/index.html index bcaa1ac..8517528 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ - + diff --git a/src/App.tsx b/src/App.tsx index 9cd67e4..70ebc81 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react"; import { BugOutlined, CheckCircleFilled, @@ -20,9 +20,7 @@ import { import ErrorBoundary from "./components/ErrorBoundary"; import ToastContainer from "./components/toast/ToastContainer"; import { toast } from "./components/toast/toastStore"; -import EcommercePage from "./features/ecommerce/EcommercePage"; import { flushPendingGenerationRecords } from "./api/generationRecordClient"; -import { ossAssets } from "./data/ossAssets"; import { keyServerClient } from "./api/keyServerClient"; import { setUserMaxConcurrency } from "./api/generationConcurrency"; import { @@ -38,6 +36,8 @@ import { useAppStore, useSessionStore } from "./stores"; import type { WebUserSession } from "./types"; import "./styles/ecommerce-standalone.css"; +const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage")); + type AuthMode = "login" | "register"; type AuthMethod = "account" | "email" | "phone"; @@ -51,17 +51,6 @@ interface LocalProfilePageProps { 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" }) { const displayName = session.user.displayName || session.user.username || "用户"; 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) { const displayName = session.user.displayName || session.user.username || "用户"; - const workCount = Math.max(imageCount + videoCount, profileWorks.length); - const projectCount = Math.max(1, Math.round(workCount / 18)); - const assetCount = Math.max(1, Math.round(workCount / 20)); + const workCount = Math.max(imageCount + videoCount, 0); + const projectCount = 0; + const assetCount = 0; return (
@@ -142,22 +131,15 @@ function LocalProfilePage({ session, balance, imageCount, videoCount, onBack, on
代表作 - 最近完成的高质量生成内容 + 后续将展示接口返回的真实作品
{workCount} 项
-
- {profileWorks.map((work) => ( -
- -
- {work.type} - {work.title} -

{work.desc}

- 已完成 · {work.time} -
-
- ))} +
+
+ 暂无代表作数据 + 作品接口接入后,这里会显示你的真实生成内容。 +
@@ -184,7 +166,6 @@ function App() { const [sessionNotice, setSessionNotice] = useState(null); const [profileMenuOpen, setProfileMenuOpen] = useState(false); const [currentPage, setCurrentPage] = useState<"workspace" | "profile">("workspace"); - const [workspaceKey, setWorkspaceKey] = useState(0); useEffect(() => { void loadDarkGreenTheme(); @@ -339,7 +320,7 @@ function App() { const balance = Math.max(usage.balanceCents, 0) / 100; const displayName = session?.user.displayName || session?.user.username || "用户"; const actualWorkCount = Math.max(usage.imageUsed + usage.videoUsed, 0); - const shownWorkCount = Math.max(actualWorkCount, profileWorks.length); + const shownWorkCount = actualWorkCount; const avatarMenuStats = useMemo( () => [ @@ -360,7 +341,6 @@ function App() { const handleOpenWorkspace = () => { setProfileMenuOpen(false); setCurrentPage("workspace"); - setWorkspaceKey((k) => k + 1); }; const handleBugFeedback = () => { @@ -447,17 +427,22 @@ function App() {
- {currentPage === "profile" && session ? ( - - ) : ( + {session ? ( + + ) : null} + {/* 工作台常驻挂载,仅用 hidden 切换。切到个人中心时不卸载, + 生成任务、进度动画、已上传图片等本地状态全部保留,切回即继续。 */} +
{authOpen ? ( diff --git a/src/api/generationRecordClient.ts b/src/api/generationRecordClient.ts index bf6e480..75e7d04 100644 --- a/src/api/generationRecordClient.ts +++ b/src/api/generationRecordClient.ts @@ -38,6 +38,20 @@ export interface SaveGenerationRecordResult { id: string; } +// 同一 clientRecordId 的保存去重:套图主流程、backgroundTaskRunner、useGenerationTasks +// 三处都可能对同一条终态任务调用 saveGenerationRecord,SSE 重复推送 completed 时 +// 单个 poller 内也会重复触发。这里做客户端幂等:in-flight 合流 + 成功后短期拦截, +// 避免后端在缺少去重时插入重复记录。 +const inFlightSaves = new Map>(); +const recentlySavedAt = new Map(); +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[] { try { const raw = window.localStorage.getItem(PENDING_RECORDS_KEY); @@ -60,6 +74,36 @@ function writePendingRecord(input: SaveGenerationRecordInput): void { } export async function saveGenerationRecord(input: SaveGenerationRecordInput): Promise { + 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 { try { const response = await serverRequest<{ id?: string | number }>("ai/generation-records", { method: "POST", diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index 32d8b24..575f197 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -156,6 +156,16 @@ const ecommerceInspirationRows = [ }, ] 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 normalizeHexColor = (value: string) => { @@ -1385,6 +1395,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [productSetRequirement, setProductSetRequirement] = useState(""); const [productSetOutput, setProductSetOutput] = useState(defaultProductSetOutput); const [productSetStatus, setProductSetStatus] = useState("idle"); + // 套图/图像生成的真实进度(0-100):多张串行生成时按"已完成张数 + 当前张子进度"推进, + // 替代进度条原先写死 50 导致卡在 75% 的假进度。 + const [generationProgress, setGenerationProgress] = useState(0); const [productSetResultImages, setProductSetResultImages] = useState([]); const [isSetUploadDragging, setIsSetUploadDragging] = useState(false); const [selectedProductSetPreview, setSelectedProductSetPreview] = useState(null); @@ -1411,9 +1424,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [watermarkResultUrl, setWatermarkResultUrl] = useState(null); const [watermarkProgress, setWatermarkProgress] = useState(0); 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 [translateLanguage, setTranslateLanguage] = useState("zh"); + const [translateResultUrl, setTranslateResultUrl] = useState(null); const [imageWorkbenchImage, setImageWorkbenchImage] = useState<{ src: string; name: string; format: string } | null>(null); const [imageWorkbenchPrompt, setImageWorkbenchPrompt] = useState(""); const [imageWorkbenchBrushSize, setImageWorkbenchBrushSize] = useState(50); @@ -1437,8 +1451,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [visibleComposerMenu, setVisibleComposerMenu] = useState(null); const [isComposerMenuClosing, setIsComposerMenuClosing] = useState(false); const [composerPopoverLeft, setComposerPopoverLeft] = useState(0); + const [composerPopoverTop, setComposerPopoverTop] = useState(0); 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 [openCloneModelSelect, setOpenCloneModelSelect] = useState(null); const [cloneModelSelectDropUp, setCloneModelSelectDropUp] = useState(false); @@ -2082,6 +2097,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { stopWatermarkProgress(); setWatermarkProgress(100); 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 { setWatermarkStatus("failed"); stopWatermarkProgress(); @@ -2128,6 +2152,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { } setActiveQuickTool(null); setTranslateStatus("idle"); + setTranslateResultUrl(null); setTranslateImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return null; @@ -2145,6 +2170,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return nextImage; }); setTranslateStatus("idle"); + setTranslateResultUrl(null); setActiveQuickTool("translate"); }; @@ -2154,6 +2180,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { translateProcessTimeoutRef.current = null; } setTranslateStatus("idle"); + setTranslateResultUrl(null); setTranslateImage((current) => { if (current?.src) URL.revokeObjectURL(current.src); return null; @@ -2182,28 +2209,76 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return nextImage; }); setTranslateStatus("idle"); + setTranslateResultUrl(null); toast.success("图片已导入"); }; - const handleTranslateGenerate = () => { + const handleTranslateGenerate = async () => { if (!translateImage || translateStatus === "processing") return; - if (translateProcessTimeoutRef.current !== null) window.clearTimeout(translateProcessTimeoutRef.current); + const targetLabel = translateLanguageOptions.find((option) => option.value === translateLanguage)?.label || "中文"; setTranslateStatus("processing"); - translateProcessTimeoutRef.current = window.setTimeout(() => { - translateProcessTimeoutRef.current = null; - setTranslateStatus("done"); - toast.success("图片翻译完成"); - }, 900); + setTranslateResultUrl(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"); + toast.success("图片翻译完成"); + 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 = () => { - if (!translateImage || translateStatus !== "done") { + if (!translateResultUrl || translateStatus !== "done") { toast.info("请先完成图片翻译"); return; } const link = document.createElement("a"); - const safeName = (translateImage.name || "translate-result").replace(/\.[^.]+$/, "").replace(/[\\/:*?"<>|]+/g, "-"); - link.href = translateImage.src; + const safeName = (translateImage?.name || "translate-result").replace(/\.[^.]+$/, "").replace(/[\\/:*?"<>|]+/g, "-"); + link.href = translateResultUrl; link.download = `${safeName || "translate-result"}-翻译.png`; document.body.appendChild(link); link.click(); @@ -2402,6 +2477,17 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { stopWorkbenchProgress(); setImageWorkbenchProgress(100); 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 { setImageWorkbenchStatus("failed"); stopWorkbenchProgress(); @@ -3557,6 +3643,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const generatedUrls: string[] = []; 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) { if (imageAbortRef.current.current) break; @@ -3580,8 +3669,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { let resultUrl: string | null = null; try { resultUrl = await waitForTask(taskId, { + kind: "image", 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 { untrackEcommerceTask(taskId); @@ -3597,6 +3692,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { generatedUrls.push(""); 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 stamp = Date.now(); + setGenerationProgress(0); const { taskId } = await aiGenerationClient.createImageTask({ prompt, @@ -3663,8 +3761,12 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { let resultUrl: string | null = null; try { resultUrl = await waitForTask(taskId, { + kind: "image", 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 { untrackEcommerceTask(taskId); @@ -4501,7 +4603,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
{productSetStatus === "generating" ? : } {productSetStatus === "generating" ? "正在生成" : "等待生成"} - {productSetStatus === "generating" ? : null} + {productSetStatus === "generating" ? : null} {productSetStatus === "generating" ? "AI 正在整理主图、场景、细节与卖点图。" : "上传商品原图并填写信息后,AI 将为您生成专业的电商商品图。"}
)} @@ -4554,7 +4656,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { language: item, 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; if (!menuToRender) return null; const popoverClosingClass = !composerMenu && isComposerMenuClosing ? " is-closing" : ""; @@ -4735,7 +4840,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const toggleComposerMenu = (menuKey: ComposerMenuKey, event: ReactMouseEvent) => { const composerRect = event.currentTarget.closest(".clone-ai-input-wrapper.ecom-command-composer")?.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)); }; @@ -4772,6 +4880,24 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { 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 = inspirationPreview && typeof document !== "undefined" ? createPortal( @@ -4790,6 +4916,18 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { ) : ( )} + {inspirationPreview.prompt ? ( +
+ +
+ ) : null} , document.body, @@ -4929,7 +5067,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { <> 正在生成 - + AI 正在为 {platform} / {market} 整理{selectedCloneOutput.label}。 ) : status === "failed" ? ( @@ -5028,7 +5166,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
{status === "generating" ? : status === "failed" ? : } {status === "generating" ? "正在生成" : status === "failed" ? "生成失败" : "等待生成"} - {status === "generating" ? : null} + {status === "generating" ? : null} {status === "generating" ? "AI 正在为 " + platform + " / " + market + " 整理" + selectedCloneOutput.label + "。" @@ -5223,7 +5361,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
{row.cards.map((card, index) => ( -
setInspirationPreview({ mediaUrl: card.mediaUrl, mediaType: card.mediaType })}> +
setInspirationPreview({ mediaUrl: card.mediaUrl, mediaType: card.mediaType, prompt: buildInspirationPrompt(card.title, card.meta) })}> - ) : translateStatus === "done" ? ( + ) : translateStatus === "done" && translateResultUrl ? ( <> - 翻译结果 + 翻译结果 + ) : translateStatus === "failed" ? ( +
+ + 翻译失败 + 请重试或更换图片 +
) : (
diff --git a/src/features/ecommerce/EcommerceProgressBar.tsx b/src/features/ecommerce/EcommerceProgressBar.tsx index f4fe063..8abf1cb 100644 --- a/src/features/ecommerce/EcommerceProgressBar.tsx +++ b/src/features/ecommerce/EcommerceProgressBar.tsx @@ -1,10 +1,11 @@ import { useSmoothedProgress } from "../../hooks/useSmoothedProgress"; -import type { ReactNode } from "react"; interface EcommerceProgressBarProps { status: "idle" | "generating" | "done" | "failed" | string; label?: string; onCancel?: () => void; + /** 0-100 真实进度。传入时进度条按真实值推进;省略时按状态做平滑蠕动。 */ + progress?: number; } function mapStatus(status: string): "running" | "completed" | "failed" { @@ -14,9 +15,13 @@ function mapStatus(status: string): "running" | "completed" | "failed" { return "running"; } -export function EcommerceProgressBar({ status, label, onCancel }: EcommerceProgressBarProps) { - const progress = mapStatus(status) === "running" ? 50 : 100; - const smoothed = useSmoothedProgress(progress, mapStatus(status)); +export function EcommerceProgressBar({ status, label, onCancel, progress }: EcommerceProgressBarProps) { + const mapped = 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; diff --git a/src/main.tsx b/src/main.tsx index 805d641..ee3b0a3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,15 +2,6 @@ import React from "react"; import ReactDOM from "react-dom/client"; import "./styles/index.css"; 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"); diff --git a/src/stores/useGenerationStore.ts b/src/stores/useGenerationStore.ts index 711aa7c..167814a 100644 --- a/src/stores/useGenerationStore.ts +++ b/src/stores/useGenerationStore.ts @@ -24,17 +24,33 @@ interface PersistedQueueSnapshot { savedAt: number; } -const STORAGE_KEY = "omniai:generation-queue"; +const STORAGE_KEY_PREFIX = "omniai:generation-queue"; const MAX_ITEMS = 80; 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[] { try { - const raw = localStorage.getItem(STORAGE_KEY); + const raw = localStorage.getItem(getStorageKey()); if (!raw) return []; const snapshot = JSON.parse(raw) as PersistedQueueSnapshot; if (Date.now() - (snapshot.savedAt || 0) > STALE_MS) { - localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(getStorageKey()); return []; } return snapshot.items.filter( @@ -48,7 +64,7 @@ function loadPersistedQueue(): GenerationQueueItem[] { function persistQueue(items: GenerationQueueItem[]): void { try { 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 */ } } @@ -63,17 +79,6 @@ interface GenerationStoreState { 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(); export const useGenerationStore = create((set, get) => ({ diff --git a/src/styles/ecommerce-standalone.css b/src/styles/ecommerce-standalone.css index f5cb420..744be23 100644 --- a/src/styles/ecommerce-standalone.css +++ b/src/styles/ecommerce-standalone.css @@ -69,6 +69,17 @@ 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 .product-clone-page { height: 100%; @@ -1346,6 +1357,34 @@ 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) { .local-profile-page__body { grid-template-columns: minmax(0, 1fr); @@ -12354,6 +12393,40 @@ body .ecom-inspiration-preview__close { 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) { body .ecom-inspiration-preview { padding: 14px !important; @@ -13934,3 +14007,77 @@ html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[d min-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; +} diff --git a/src/styles/pages/ecommerce.css b/src/styles/pages/ecommerce.css index 4ce9dee..8d71d84 100644 --- a/src/styles/pages/ecommerce.css +++ b/src/styles/pages/ecommerce.css @@ -12093,3 +12093,110 @@ html body #root .ecommerce-standalone.ecommerce-standalone .product-clone-page[d 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; +} diff --git a/vite.config.ts b/vite.config.ts index d963bac..1688ca7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,8 +2,14 @@ import react from "@vitejs/plugin-react"; import { compression } from "vite-plugin-compression2"; import { defineConfig } from "vite"; -export default defineConfig(() => { - const devApiTarget = process.env.OMNIAI_DEV_API_TARGET?.trim(); +export default defineConfig(({ command }) => { + // 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 ? { "/api": { @@ -27,9 +33,7 @@ export default defineConfig(() => { port: 4174, host: "127.0.0.1", }, - esbuild: { - drop: ["console", "debugger"], - }, + ...(command === "build" ? { esbuild: { drop: ["console", "debugger"] } } : {}), build: { sourcemap: false, rollupOptions: {