diff --git a/app/api/canvas/[id]/detail-archives/route.ts b/app/api/canvas/[id]/detail-archives/route.ts index 8f56d78d6..04878ab0c 100644 --- a/app/api/canvas/[id]/detail-archives/route.ts +++ b/app/api/canvas/[id]/detail-archives/route.ts @@ -224,3 +224,53 @@ export async function PATCH( return NextResponse.json({ success: false, error: "更新详情存档失败" }, { status: 500 }); } } + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + } + + const { id: projectId } = await params; + const ownsProject = await ensureProjectOwnership(projectId, session.user.id); + if (!ownsProject) { + return NextResponse.json({ success: false, error: "画布不存在或无权限" }, { status: 404 }); + } + + const searchArchiveId = request.nextUrl.searchParams.get("archiveId"); + let bodyArchiveId = ""; + if (!searchArchiveId) { + const body = await request.json().catch(() => ({})); + bodyArchiveId = String((body as any)?.archiveId || "").trim(); + } + const archiveId = String(searchArchiveId || bodyArchiveId || "").trim(); + + if (!archiveId) { + return NextResponse.json({ success: false, error: "缺少存档ID" }, { status: 400 }); + } + + const [deleted] = await db + .delete(canvasDetailArchive) + .where( + and( + eq(canvasDetailArchive.id, archiveId), + eq(canvasDetailArchive.projectId, projectId), + eq(canvasDetailArchive.userId, session.user.id) + ) + ) + .returning(); + + if (!deleted) { + return NextResponse.json({ success: false, error: "存档不存在或无权限" }, { status: 404 }); + } + + return NextResponse.json({ success: true, data: deleted }); + } catch (error) { + console.error("[CanvasDetailArchive] DELETE failed:", error); + return NextResponse.json({ success: false, error: "删除详情存档失败" }, { status: 500 }); + } +} diff --git a/app/api/canvas/detail-plan/route.ts b/app/api/canvas/detail-plan/route.ts index 210009a0d..d6efa4bd3 100644 --- a/app/api/canvas/detail-plan/route.ts +++ b/app/api/canvas/detail-plan/route.ts @@ -170,6 +170,14 @@ type DetailPlanPreflight = { rewriteApplied: boolean; }; +type DetailCoreFocusContext = { + anchor: string; + primaryNeed: string; + firstScreenTitle: string; + firstScreenSubtitle: string; + focusTerms: string[]; +}; + const AGENT_PLANNING_PHRASE_PATTERNS = [ /第[一二三四五六七八九十0-9]+屏(?:展示|讲|说明|介绍|主要|重点|先讲|再讲)?/gi, /本屏(?:展示|讲|说明|介绍|主要|重点|先讲|再讲)?/gi, @@ -249,6 +257,294 @@ function visibleTitleLength(value: string): number { .trim().length; } +function splitToCoreTerms(value: string, language: "zh" | "en"): string[] { + const raw = String(value || "").trim(); + if (!raw) return []; + if (language === "en") { + return Array.from( + new Set( + raw + .toLowerCase() + .split(/[^a-z0-9]+/i) + .map((term) => term.trim()) + .filter((term) => term.length >= 3) + .slice(0, 6) + ) + ); + } + const compact = raw.replace(/\s+/g, ""); + const clauses = compact + .split(/[,。;、::,.!?!?/|()()\-—]+/g) + .map((item) => item.trim()) + .filter(Boolean); + const seed = clauses[0] || compact; + const terms = new Set(); + if (seed.length >= 2) terms.add(seed.slice(0, Math.min(6, seed.length))); + if (seed.length >= 4) terms.add(seed.slice(0, 4)); + terms.add(seed); + return Array.from(terms).filter((term) => term.length >= 2).slice(0, 6); +} + +function compactZhTitle(value: string): string { + const base = String(value || "") + .replace(/[“”"'`]/g, "") + .replace(/[,。;、::,.!?!?/|()()\-—\s]+/g, "") + .trim(); + if (!base) return "核心不妥协"; + if (base.length > 10) return base.slice(0, 10); + if (base.length < 4) return `${base}升级`; + return base; +} + +function compactEnTitle(value: string): string { + const words = String(value || "") + .replace(/[“”"'`]/g, "") + .replace(/\s+/g, " ") + .trim() + .split(/[^a-z0-9]+/i) + .map((word) => word.trim()) + .filter(Boolean); + if (words.length === 0) return "Core Upgrade"; + return words.slice(0, 4).join(" "); +} + +function inferZhProductSuffix(productName: string): string { + const name = String(productName || "").replace(/\s+/g, ""); + if (!name) return ""; + const rules: Array<{ pattern: RegExp; suffix: string }> = [ + { pattern: /自拍杆|稳定杆|补光杆/, suffix: "杆" }, + { pattern: /尿不湿|纸尿裤|拉拉裤|尿裤/, suffix: "裤" }, + { pattern: /面膜/, suffix: "膜" }, + { pattern: /精华|精华液/, suffix: "精华" }, + { pattern: /洗发水|洗发露/, suffix: "露" }, + { pattern: /沐浴露/, suffix: "露" }, + { pattern: /牙膏/, suffix: "膏" }, + { pattern: /椅子|座椅|办公椅|人体工学椅/, suffix: "椅" }, + { pattern: /枕头|枕/, suffix: "枕" }, + { pattern: /书桌|桌子|电脑桌/, suffix: "桌" }, + { pattern: /收纳柜|收纳架|收纳箱/, suffix: "架" }, + ]; + const matched = rules.find((rule) => rule.pattern.test(name)); + if (matched) return matched.suffix; + + const tailMatch = name.match(/[杆裤膜露膏椅枕桌架杯包箱机器锅刷]/g); + if (tailMatch && tailMatch.length > 0) { + return tailMatch[tailMatch.length - 1] || ""; + } + return ""; +} + +function inferZhNamingStem(anchor: string, primaryNeed: string): string { + const source = `${anchor} ${primaryNeed}`.replace(/\s+/g, ""); + const rules: Array<{ pattern: RegExp; stem: string }> = [ + { pattern: /抗过敏|防过敏|敏感|低敏|温和/, stem: "安敏" }, + { pattern: /甩|一甩|快速展开/, stem: "甩甩" }, + { pattern: /防抖|稳定|稳拍/, stem: "稳拍" }, + { pattern: /轻|便携|小巧/, stem: "轻享" }, + { pattern: /护腰|贴腰|腰撑/, stem: "护腰" }, + { pattern: /透气|呼吸感/, stem: "透气" }, + { pattern: /省空间|收纳/, stem: "省位" }, + { pattern: /抗菌|抑菌|洁净/, stem: "净护" }, + ]; + const matched = rules.find((rule) => rule.pattern.test(source)); + if (matched) return matched.stem; + + const compact = compactZhTitle(anchor); + if (!compact) return "安心"; + if (compact.length === 1) return `${compact}${compact}`; + return compact.slice(0, Math.min(4, compact.length)); +} + +const ZH_NAME_BANNED_WORDS = [ + "核心", + "价值", + "方案", + "系统", + "智能", + "科技", + "场景", + "升级", + "高端", + "专业", +]; + +function toColloquialZhStem(stem: string): string { + let next = String(stem || "").replace(/\s+/g, ""); + if (!next) return "好用"; + const map: Array<{ pattern: RegExp; value: string }> = [ + { pattern: /快速|秒开|一秒|展开|甩/, value: "甩甩" }, + { pattern: /防抖|稳定|稳拍|不晃/, value: "稳稳" }, + { pattern: /抗过敏|安敏|低敏|敏感/, value: "安敏" }, + { pattern: /透气|不闷|呼吸感/, value: "透透" }, + { pattern: /防漏/, value: "防漏" }, + { pattern: /护腰|贴腰|腰撑/, value: "护腰" }, + { pattern: /省空间|收纳|整洁/, value: "省位" }, + { pattern: /轻便|小巧|便携/, value: "轻轻" }, + ]; + const hit = map.find((item) => item.pattern.test(next)); + if (hit) { + next = hit.value; + } + ZH_NAME_BANNED_WORDS.forEach((token) => { + next = next.replace(new RegExp(token, "g"), ""); + }); + next = next.replace(/[,。;、::,.!?!?/|()()\-—]/g, ""); + if (!next) return "好用"; + if (next.length === 1) return `${next}${next}`; + if (next.length > 4) return next.slice(0, 4); + return next; +} + +function buildZhCoreNamingTitle(brief: DetailBrief, anchor: string, primaryNeed: string): string { + const suffix = inferZhProductSuffix(brief.productName); + let stem = toColloquialZhStem(inferZhNamingStem(anchor, primaryNeed)); + if (!suffix) return compactZhTitle(anchor); + + if (stem.length > 4) { + stem = stem.slice(0, 4); + } + if (stem.length === 1) { + stem = `${stem}${stem}`; + } + + let title = stem.endsWith(suffix) ? stem : `${stem}${suffix}`; + if (title.length < 3) { + title = `${compactZhTitle(anchor).slice(0, 2)}${suffix}`; + } + if (title.length > 10) { + title = title.slice(0, 10); + } + return title; +} + +function buildCoreFocusContext(brief: DetailBrief, language: "zh" | "en"): DetailCoreFocusContext { + const anchorRaw = + brief.coreSellingPoint || + brief.positioningStatement || + brief.coreNeed || + (language === "en" ? "core value" : "核心卖点"); + const scenario = brief.usageScenario || (language === "en" ? "daily use" : "日常场景"); + const primaryNeed = brief.coreNeed || (language === "en" ? "key demand" : "核心需求"); + const riskText = brief.riskConcern || (language === "en" ? "extra effort" : "反复折腾"); + const expectedText = brief.expectedResult || primaryNeed; + + const focusTerms = Array.from( + new Set([ + ...splitToCoreTerms(anchorRaw, language), + ...splitToCoreTerms(primaryNeed, language), + ...splitToCoreTerms(expectedText, language), + ]) + ).slice(0, 8); + + if (language === "en") { + const title = compactEnTitle(anchorRaw); + const subtitle = `In ${scenario}, achieve ${expectedText} without ${riskText}.`; + return { + anchor: anchorRaw, + primaryNeed, + firstScreenTitle: title, + firstScreenSubtitle: subtitle, + focusTerms, + }; + } + + const title = buildZhCoreNamingTitle(brief, anchorRaw, primaryNeed); + const subtitle = `在${scenario}场景,不用${riskText}也能${expectedText}。`; + return { + anchor: anchorRaw, + primaryNeed, + firstScreenTitle: title, + firstScreenSubtitle: subtitle, + focusTerms: Array.from( + new Set([...focusTerms, ...splitToCoreTerms(title, language)]) + ), + }; +} + +function containsCoreTerms(text: string, terms: string[]): boolean { + const source = String(text || "").toLowerCase(); + if (!source || terms.length === 0) return false; + return terms.some((term) => source.includes(String(term || "").toLowerCase())); +} + +function enforceCoreFocusOnScreens(params: { + screens: DetailPlanScreen[]; + coreFocus: DetailCoreFocusContext; + language: "zh" | "en"; +}): DetailPlanScreen[] { + const { screens, coreFocus, language } = params; + const namingWord = coreFocus.firstScreenTitle; + return screens.map((screen, index) => { + const mergedText = `${screen.title} ${screen.subtitle} ${screen.copy} ${screen.fabe.b}`; + const focused = containsCoreTerms(mergedText, coreFocus.focusTerms); + const promptCoreLine = + language === "en" + ? `Core selling-point anchor: ${coreFocus.anchor}. This frame must serve this same anchor and avoid topic drift.` + : `核心卖点锚点:${coreFocus.anchor}。本屏必须服务同一核心卖点,不得偏题。`; + + let title = screen.title; + let subtitle = screen.subtitle; + let objective = screen.objective; + let copy = screen.copy; + let benefit = screen.fabe.b; + + if (index === 0) { + title = + language === "zh" + ? coreFocus.firstScreenTitle + : hasPlanningTitleMeta(title) || !containsCoreTerms(title, coreFocus.focusTerms) + ? coreFocus.firstScreenTitle + : title; + subtitle = containsCoreTerms(`${subtitle} ${copy}`, coreFocus.focusTerms) + ? subtitle + : coreFocus.firstScreenSubtitle; + } + + if (!containsCoreTerms(objective, coreFocus.focusTerms)) { + objective = + language === "en" + ? `${objective}. Keep reinforcing "${coreFocus.anchor}".` + : `${objective},持续强化“${coreFocus.anchor}”。`; + } + + if (!focused) { + copy = + language === "en" + ? `${copy} Keep all messaging aligned to "${coreFocus.anchor}" and "${coreFocus.primaryNeed}".` + : `${copy} 全屏文案围绕“${coreFocus.anchor}”,统一服务“${coreFocus.primaryNeed}”。`; + benefit = + language === "en" + ? `Deliver "${coreFocus.primaryNeed}" under "${coreFocus.anchor}" with lower effort and clearer payoff.` + : `在“${coreFocus.anchor}”下交付“${coreFocus.primaryNeed}”结果,降低决策与使用成本。`; + } + + if ( + language === "zh" && + index > 0 && + index <= 3 && + namingWord && + !String(`${subtitle} ${copy}`).includes(namingWord) + ) { + copy = `${copy} 持续强化“${namingWord}”这一记忆点,形成独有搜索词。`; + } + + return { + ...screen, + title, + subtitle, + objective, + copy, + fabe: { + ...screen.fabe, + b: benefit, + }, + visualPrompt: screen.visualPrompt.includes(coreFocus.anchor) + ? screen.visualPrompt + : `${screen.visualPrompt} ${promptCoreLine}`, + }; + }); +} + function buildTypographySpec(language: "zh" | "en") { if (language === "en") { return { @@ -467,6 +763,7 @@ function buildFallbackScreens(params: { count: number; language: "zh" | "en"; brief: DetailBrief; + coreFocus: DetailCoreFocusContext; globalStyle: DetailPlanResponse["globalStyle"]; referenceImageNotes: string[]; hasModelImage: boolean; @@ -478,6 +775,7 @@ function buildFallbackScreens(params: { count, language, brief, + coreFocus, globalStyle, referenceImageNotes, hasModelImage, @@ -510,8 +808,20 @@ function buildFallbackScreens(params: { const screenTypeSequence: DetailScreenType[] = ["B", "A", "B", "C", "B"]; const defaultTitles = language === "en" - ? ["Long-Sit Comfort", "Waist Support", "One-Step Adjust", "Stable Structure", "Verified Trust"] - : ["久坐不累", "贴腰承托", "一键调节", "稳固支撑", "安心可证"]; + ? [ + coreFocus.firstScreenTitle, + "Core Benefit Proof", + "Mechanism Breakdown", + "Difference Reinforcement", + "Trust Evidence", + ] + : [ + coreFocus.firstScreenTitle, + "核心收益可证", + "机制拆解", + "差异强化", + "证据收口", + ]; return Array.from({ length: count }).map((_, index) => { const screenType = @@ -535,17 +845,19 @@ function buildFallbackScreens(params: { ? language === "en" ? `Show product pairing with ${bundleImageCount} optional companion SKU(s) to increase basket size.` : `联动${bundleImageCount}张搭配产品参考图,强化关联销售与加购动机。` + : index === 0 + ? coreFocus.firstScreenSubtitle : language === "en" - ? `Solve "${coreNeed}" in ${mainScenario} with clear benefit-focused messaging.` - : `在${mainScenario}场景解决“${coreNeed}”,突出结果导向表达。`; + ? `Around "${coreFocus.anchor}", solve "${coreNeed}" in ${mainScenario} with clear benefit-focused messaging.` + : `围绕“${coreFocus.anchor}”,在${mainScenario}场景解决“${coreNeed}”,突出结果导向表达。`; const copy = isBundleScreen ? language === "en" ? `${productLabel} is presented with matching companion products in one coherent scene to drive add-on purchase.` : `${productLabel}与可搭配产品同场景呈现,突出组合价值与加购理由。` : language === "en" - ? `${productLabel} turns key pain points into visible outcomes with a clear decision path.` - : `${productLabel}将关键痛点转为可感知结果,形成清晰购买决策路径。`; + ? `${productLabel} turns key pain points into visible outcomes under the anchor "${coreFocus.anchor}", keeping one consistent decision narrative.` + : `${productLabel}围绕“${coreFocus.anchor}”将关键痛点转为可感知结果,形成统一的购买决策路径。`; const layoutNote = isBundleScreen ? language === "en" @@ -593,6 +905,7 @@ function buildFallbackScreens(params: { `Keep exact product identity consistent across all frames.`, `Main scene: ${mainScenario}; core demand: ${coreNeed}.`, "Audience profile is internal strategy only; do not expose explicit age/gender labels in visible copy.", + `Core selling-point anchor: ${coreFocus.anchor}; this frame must stay aligned to this anchor.`, `Positioning statement: ${positioning}.`, `Global style: ${globalStyle.designLanguage}.`, `Typography: ${globalStyle.typography.fontFamily}; ${globalStyle.typography.title}; ${globalStyle.typography.subtitle}; ${globalStyle.typography.body}.`, @@ -616,6 +929,7 @@ function buildFallbackScreens(params: { "全套保持同一产品外观一致(结构、材质、颜色、比例、Logo)。", `主场景:${mainScenario};核心需求:${coreNeed}。`, "人群画像仅用于内部策略,画面可见文案不得直写年龄/性别标签。", + `核心卖点锚点:${coreFocus.anchor},本屏文案必须围绕该锚点。`, `一句话定位:${positioning}。`, `全局风格:${globalStyle.designLanguage}。`, `字体规范:${globalStyle.typography.fontFamily};${globalStyle.typography.title};${globalStyle.typography.subtitle};${globalStyle.typography.body}。`, @@ -655,6 +969,7 @@ function buildFallbackPlan(params: { count: number; language: "zh" | "en"; brief: DetailBrief; + coreFocus: DetailCoreFocusContext; referenceImageNotes: string[]; hasModelImage: boolean; hasBundleImages: boolean; @@ -663,14 +978,20 @@ function buildFallbackPlan(params: { const { count, language } = params; const globalStyle = buildDefaultGlobalStyle(language); const skillFlow = buildDetailSkillFlow(language); - const screens = buildFallbackScreens({ + const fallbackScreens = buildFallbackScreens({ ...params, globalStyle, }); + const screens = enforceCoreFocusOnScreens({ + screens: fallbackScreens, + coreFocus: params.coreFocus, + language, + }); const qualityChecks = evaluatePlanQuality({ screens, hasModelImage: params.hasModelImage, hasBundleImages: params.hasBundleImages, + coreFocus: params.coreFocus, }); return { summary: @@ -810,8 +1131,9 @@ function evaluatePlanQuality(params: { screens: DetailPlanScreen[]; hasModelImage: boolean; hasBundleImages: boolean; + coreFocus?: DetailCoreFocusContext; }): { passed: boolean; issues: string[] } { - const { screens, hasModelImage, hasBundleImages } = params; + const { screens, hasModelImage, hasBundleImages, coreFocus } = params; const issues: string[] = []; if (screens.length === 0) { issues.push("未生成任何分镜。"); @@ -862,12 +1184,13 @@ function evaluatePlanQuality(params: { issues.push("主标题不应使用规划节奏词(如“方案拆解/细节证明/信任收口”等)。"); } - const titleLengthOutOfRange = screens.some((screen) => { + const titleLengthOutOfRange = screens.some((screen, index) => { const len = visibleTitleLength(screen.title); - return len < 4 || len > 10; + const minLen = index === 0 ? 3 : 4; + return len < minLen || len > 10; }); if (titleLengthOutOfRange) { - issues.push("存在分镜主标题不在4-10字区间。"); + issues.push("存在分镜主标题长度不符合规范(首屏3-10字,其余4-10字)。"); } const subtitleTooLong = screens.some((screen) => { @@ -886,6 +1209,33 @@ function evaluatePlanQuality(params: { issues.push("存在空泛形容词(如“高级感/轻奢/超好用”),需改为可感知结果。"); } + if (coreFocus && coreFocus.focusTerms.length > 0 && screens.length > 0) { + const firstScreen = screens[0]; + const firstScreenText = `${firstScreen?.title || ""} ${firstScreen?.subtitle || ""} ${firstScreen?.copy || ""}`; + if (!containsCoreTerms(firstScreenText, coreFocus.focusTerms)) { + issues.push("首屏未聚焦核心卖点锚点,主标题与副标题需直接承接核心卖点。"); + } + + const offTopicCount = screens.filter((screen) => { + const merged = `${screen.objective} ${screen.subtitle} ${screen.copy} ${screen.fabe.b}`; + return !containsCoreTerms(merged, coreFocus.focusTerms); + }).length; + const allowOffTopic = Math.max(1, Math.floor(screens.length * 0.15)); + if (offTopicCount > allowOffTopic) { + issues.push("多屏文案偏离核心卖点,整套详情需围绕同一核心卖点展开。"); + } + + const namingWord = String(firstScreen?.title || "").trim(); + if (namingWord) { + const repeatCount = screens + .slice(1) + .filter((screen) => String(`${screen.subtitle} ${screen.copy}`).includes(namingWord)).length; + if (repeatCount < Math.min(2, Math.max(1, screens.length - 1))) { + issues.push("首屏命名词未在后续页面重复强化,建议至少2屏复现以沉淀新搜索词。"); + } + } + } + if (hasModelImage) { const modelScreenCount = screens.filter((screen) => screen.useModel).length; if (modelScreenCount === 0) { @@ -910,6 +1260,7 @@ async function runDualDirectorPlanPreflight(params: { model: any; language: "zh" | "en"; brief: DetailBrief; + coreFocus: DetailCoreFocusContext; globalStyle: DetailPlanResponse["globalStyle"]; screens: DetailPlanScreen[]; hasModelImage: boolean; @@ -958,6 +1309,7 @@ async function runDualDirectorPlanPreflight(params: { "Title must not use planning rhythm terms like: core value / pain resonance / solution logic / detail proof / trust closure.", "Do not expose explicit age/gender labels in visible copy (e.g., \"25-35 female\"); rewrite to scenario-based wording.", "Must keep canvas 790x1300, unified typography hierarchy, and consistent style.", + `All frames must stay centered on one anchor value: "${params.coreFocus.anchor}".`, params.hasModelImage ? "Model image is provided: only use where needed, keep identity consistent." : "No model image: all frames must avoid human model dependence.", @@ -980,6 +1332,7 @@ async function runDualDirectorPlanPreflight(params: { "禁用表达:主标题禁止出现“核心价值/场景痛点/方案拆解/细节证明/信任收口/卖点延展”等节奏词。", "禁用表达:对外文案不得直写具体年龄/性别标签(如“25-35岁女性”),必须改为场景化表达。", "必须维持 790x1300 固定规格、全套统一字体层级与统一风格。", + `整套文案必须围绕同一核心卖点锚点:“${params.coreFocus.anchor}”,不得跑题。`, params.hasModelImage ? "已提供模特图:仅在必要分镜使用且保持同一人物一致性。" : "未提供模特图:所有分镜不得依赖模特人物。", @@ -1095,11 +1448,13 @@ export async function POST(request: Request) { { status: 400 }, ); } + const coreFocus = buildCoreFocusContext(brief, language); const fallback = buildFallbackPlan({ prompt, count, language, brief, + coreFocus, referenceImageNotes, hasModelImage, hasBundleImages, @@ -1160,6 +1515,10 @@ export async function POST(request: Request) { `输出语言:${language === "en" ? "English" : "中文"}`, "任务类型:电商详情页“AI详情生成”", "必须遵循“详情升级文案方案”:定位卡 -> 卖点到买点转译 -> 黄金五屏 -> FABE闭环。", + `核心卖点锚点(整套必须围绕):${coreFocus.anchor}`, + `首屏标题参考(必须承接核心卖点):${coreFocus.firstScreenTitle}`, + "首屏命名规则:用核心卖点给产品命名(如“甩甩杆/安敏裤”),避免通用标题。", + "命名风格要求:大白话、易记、朗朗上口,未来可直接成为用户搜索词。", referenceImageNotes.length > 0 ? `参考图说明(按上传顺序):${referenceImageNotes.join(" | ")}` : "", @@ -1201,6 +1560,11 @@ export async function POST(request: Request) { `3) 所有分镜风格统一、构图统一、文案排版风格统一。主标题/副标题/正文的字体家族、字重、字号层级、行距必须全套统一。\n` + `4) 每屏尺寸固定 790x1300 像素,输出提示词必须显式包含该尺寸约束。\n` + `5) 结构遵循“黄金五屏原则”:屏1-2核心卖点,屏3-4差异强化,屏5证据背书;更多屏作为延展。\n` + + `5.1) 首屏必须输出“The One核心卖点标题”(3-10字)+ 单句结果副标题,直接回答“为什么值得买”。\n` + + `5.2) 全套每一屏都必须服务同一核心卖点锚点,不允许出现与核心卖点无关的新主叙事。\n` + + `5.3) 中文首屏标题优先使用“核心卖点命名词+品类后缀”结构(如:甩甩杆、安敏裤)。\n` + + `5.4) 命名词必须大白话、好记、可口头传播,避免“智能/科技/方案/系统”等抽象词。\n` + + `5.5) 后续至少2屏在副标题或正文复现该命名词,强化用户检索记忆。\n` + `6) 每屏都要有 FABE 闭环(B利益/F特征/A优势/E证据),且文案符合电商转化表达。\n` + `7) 文案用词需贴近用户语言,不写空泛形容词,不泄露系统流程。\n` + `7.1) 必须执行“卖点→用户任务(JTBD)→买点句”转译,买点句优先用“不用___也能___ / 在___场景也能___”句式。\n` + @@ -1221,7 +1585,7 @@ export async function POST(request: Request) { ? `14) 已提供搭配产品图:必须新增且仅新增1屏“场景化产品搭配展示”,该屏设定 bundleShowcase=true,并在 bundleReason 说明搭配策略;其余屏设为 bundleShowcase=false。\n` : `14) 未提供搭配产品图:所有分镜均设置 bundleShowcase=false。\n`) + `输出格式:\n` + - `{"summary":"简述","globalStyle":{"designLanguage":"一句话风格方向","typography":{"fontFamily":"字体家族","title":"主标题规范","subtitle":"副标题规范","body":"正文规范"},"layout":{"grid":"栅格","safeArea":"安全区","rhythm":"留白节奏"},"palette":"配色规范"},"screens":[{"index":1,"screenType":"B","objective":"本屏目标","title":"4-8字标题","subtitle":"一句副标题","copy":"正文文案","layoutNote":"排版说明","useModel":false,"modelReason":"是否需要人物及原因","bundleShowcase":false,"bundleReason":"是否为搭配展示页及原因","fabe":{"b":"利益","f":"特征","a":"优势","e":"证据"},"visualPrompt":"用于生图的完整提示词,必须包含790x1300与统一字体排版"}]}`, + `{"summary":"简述","globalStyle":{"designLanguage":"一句话风格方向","typography":{"fontFamily":"字体家族","title":"主标题规范","subtitle":"副标题规范","body":"正文规范"},"layout":{"grid":"栅格","safeArea":"安全区","rhythm":"留白节奏"},"palette":"配色规范"},"screens":[{"index":1,"screenType":"B","objective":"本屏目标","title":"3-8字标题","subtitle":"一句副标题","copy":"正文文案","layoutNote":"排版说明","useModel":false,"modelReason":"是否需要人物及原因","bundleShowcase":false,"bundleReason":"是否为搭配展示页及原因","fabe":{"b":"利益","f":"特征","a":"优势","e":"证据"},"visualPrompt":"用于生图的完整提示词,必须包含790x1300与统一字体排版"}]}`, temperature: 0.25, maxTokens: 1800, } as any); @@ -1276,22 +1640,29 @@ export async function POST(request: Request) { bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。", } ); - const qualityChecksInitial = evaluatePlanQuality({ + const coreFocusedScreens = enforceCoreFocusOnScreens({ screens: normalizedScreens, + coreFocus, + language, + }); + const qualityChecksInitial = evaluatePlanQuality({ + screens: coreFocusedScreens, hasModelImage, hasBundleImages, + coreFocus, }); const preflight = await runDualDirectorPlanPreflight({ model, language, brief, + coreFocus, globalStyle: globalStyleInitial, - screens: normalizedScreens, + screens: coreFocusedScreens, hasModelImage, hasBundleImages, }); - const preflightScreens = preflight.candidateScreens.map((item) => + const preflightScreensRaw = preflight.candidateScreens.map((item) => hasBundleImages ? item : { @@ -1300,10 +1671,16 @@ export async function POST(request: Request) { bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。", } ); + const preflightScreens = enforceCoreFocusOnScreens({ + screens: preflightScreensRaw, + coreFocus, + language, + }); const qualityChecksAfterPreflight = evaluatePlanQuality({ screens: preflightScreens, hasModelImage, hasBundleImages, + coreFocus, }); const preflightPassed = (preflight.passed || preflight.rewriteApplied) && diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index cc3c1cfb0..1424b24ce 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -5,7 +5,7 @@ import { DefaultChatTransport } from "ai"; import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import { createPortal } from "react-dom"; import useSWR, { useSWRConfig } from "swr"; -import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye } from "lucide-react"; +import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, Maximize2, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { @@ -1146,6 +1146,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const [detailSlotUploading, setDetailSlotUploading] = useState(null); const [detailSlotPickerIndex, setDetailSlotPickerIndex] = useState(null); const [detailArchives, setDetailArchives] = useState([]); + const [deletingDetailArchiveId, setDeletingDetailArchiveId] = useState(null); const [detailBrief, setDetailBrief] = useState({ productSpu: "", demandType: "", @@ -3365,6 +3366,38 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF toast.success("已加载历史详情项目,可继续修改后重新生成(会自动存为新存档)"); }, []); + const deleteDetailArchive = useCallback( + async (archiveId: string) => { + if (!archiveId || deletingDetailArchiveId === archiveId) return; + const targetArchive = detailArchives.find((item) => item.id === archiveId); + const targetTitle = targetArchive?.title || "该存档"; + if (!window.confirm(`确认删除“${targetTitle}”吗?删除后不可恢复。`)) { + return; + } + + setDeletingDetailArchiveId(archiveId); + try { + const response = await fetch( + `/api/canvas/${canvasId}/detail-archives?archiveId=${encodeURIComponent(archiveId)}`, + { method: "DELETE" } + ); + const result = await response.json().catch(() => ({})); + if (!response.ok || !result?.success) { + throw new Error(String(result?.error || "删除失败")); + } + + setDetailArchives((prev) => prev.filter((item) => item.id !== archiveId)); + toast.success("已删除历史存档"); + } catch (error) { + console.error("[CanvasChat] Failed to delete detail archive:", error); + toast.error(error instanceof Error ? error.message : "删除历史存档失败"); + } finally { + setDeletingDetailArchiveId((current) => (current === archiveId ? null : current)); + } + }, + [canvasId, deletingDetailArchiveId, detailArchives] + ); + const handleDetailGeneration = useCallback(async () => { if (isDetailGenerating) return; @@ -6551,13 +6584,29 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF {archive.imageCount || 0}张输出 - +
+ + +
))} diff --git a/components/canvas/RealtimeCanvasDialog.tsx b/components/canvas/RealtimeCanvasDialog.tsx index 3e179b12a..de76a6e43 100644 --- a/components/canvas/RealtimeCanvasDialog.tsx +++ b/components/canvas/RealtimeCanvasDialog.tsx @@ -1,6 +1,14 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ChangeEvent, + type PointerEvent as ReactPointerEvent, +} from "react"; import * as fal from "@fal-ai/serverless-client"; import { Dialog, @@ -17,6 +25,9 @@ import { Eraser, Loader2, Paintbrush, + Square, + Circle as CircleIcon, + Upload, RefreshCw, Send, Sparkles, @@ -64,6 +75,7 @@ type RealtimeCanvasDialogProps = { }; const FALLBACK_PROMPT = "将草图渲染为高质感产品设计图,保持结构和主轮廓一致。"; +const CANVAS_BACKGROUND = "#ffffff"; const defaultRealtimeConfig: RealtimeConfig = { transport: { @@ -100,6 +112,10 @@ function buildImageDataUrl(image: any) { return ""; } +function isShapeMode(mode: "brush" | "eraser" | "rect" | "circle") { + return mode === "rect" || mode === "circle"; +} + async function canvasToJpegDataUri( canvas: HTMLCanvasElement, maxInputResolution: number, @@ -120,7 +136,7 @@ async function canvasToJpegDataUri( throw new Error("无法初始化导出画布"); } - exportCtx.fillStyle = "#d8d8db"; + exportCtx.fillStyle = CANVAS_BACKGROUND; exportCtx.fillRect(0, 0, exportWidth, exportHeight); exportCtx.drawImage(canvas, 0, 0, exportWidth, exportHeight); @@ -150,6 +166,8 @@ export function RealtimeCanvasDialog({ const connectionRef = useRef(null); const pointerDownRef = useRef(false); const lastPointRef = useRef<{ x: number; y: number } | null>(null); + const shapeStartPointRef = useRef<{ x: number; y: number } | null>(null); + const shapeSnapshotRef = useRef(null); const pendingFlushTimerRef = useRef(null); const flushInFlightRef = useRef(false); const dirtyRef = useRef(false); @@ -165,6 +183,7 @@ export function RealtimeCanvasDialog({ const historyRef = useRef([]); const historyIndexRef = useRef(-1); const cursorRef = useRef(null); + const uploadInputRef = useRef(null); const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); @@ -174,7 +193,7 @@ export function RealtimeCanvasDialog({ const [prompt, setPrompt] = useState(FALLBACK_PROMPT); const [previewUrl, setPreviewUrl] = useState(""); const [isApplying, setIsApplying] = useState(false); - const [brushMode, setBrushMode] = useState<"brush" | "eraser">("brush"); + const [brushMode, setBrushMode] = useState<"brush" | "eraser" | "rect" | "circle">("brush"); const [brushSize, setBrushSize] = useState(6); const [adaptiveThrottleMs, setAdaptiveThrottleMs] = useState( defaultRealtimeConfig.transport.throttleInterval @@ -293,8 +312,11 @@ export function RealtimeCanvasDialog({ ); const scheduleFlush = useCallback( - (force = false) => { - dirtyRef.current = true; + (force = false, markDirty = true) => { + if (markDirty) { + dirtyRef.current = true; + } + if (!dirtyRef.current) return; if (force) { if (pendingFlushTimerRef.current) { window.clearTimeout(pendingFlushTimerRef.current); @@ -351,7 +373,7 @@ export function RealtimeCanvasDialog({ if (!ctx) return; ctx.save(); ctx.globalCompositeOperation = "source-over"; - ctx.fillStyle = "#d8d8db"; + ctx.fillStyle = CANVAS_BACKGROUND; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.restore(); @@ -444,64 +466,138 @@ export function RealtimeCanvasDialog({ [brushMode, brushSize] ); + const drawShape = useCallback( + (from: { x: number; y: number }, to: { x: number; y: number }) => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const minX = Math.min(from.x, to.x); + const minY = Math.min(from.y, to.y); + const width = Math.abs(to.x - from.x); + const height = Math.abs(to.y - from.y); + + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + ctx.strokeStyle = "#18181b"; + ctx.lineWidth = Math.max(1, brushSize * 0.9); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + if (brushMode === "rect") { + ctx.strokeRect(minX, minY, width, height); + } else if (brushMode === "circle") { + const centerX = minX + width / 2; + const centerY = minY + height / 2; + const radiusX = width / 2; + const radiusY = height / 2; + ctx.beginPath(); + ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2); + ctx.stroke(); + } + ctx.restore(); + }, + [brushMode, brushSize] + ); + const handlePointerDown = useCallback( (event: ReactPointerEvent) => { const point = getCanvasPoint(event); if (!point) return; pointerDownRef.current = true; lastPointRef.current = point; + if (isShapeMode(brushMode)) { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d", { willReadFrequently: true }); + shapeStartPointRef.current = point; + shapeSnapshotRef.current = + canvas && ctx ? ctx.getImageData(0, 0, canvas.width, canvas.height) : null; + } event.currentTarget.setPointerCapture(event.pointerId); }, - [getCanvasPoint] + [brushMode, getCanvasPoint] ); const handlePointerLeave = useCallback(() => { + const wasDrawing = pointerDownRef.current; if (cursorRef.current) { cursorRef.current.style.opacity = '0'; } pointerDownRef.current = false; lastPointRef.current = null; - scheduleFlush(true); + shapeStartPointRef.current = null; + shapeSnapshotRef.current = null; + if (wasDrawing) { + scheduleFlush(true, false); + } }, [scheduleFlush]); const handlePointerEnter = useCallback(() => { if (cursorRef.current) { - cursorRef.current.style.opacity = '1'; + cursorRef.current.style.opacity = brushMode === "brush" || brushMode === "eraser" ? "1" : "0"; } - }, []); + }, [brushMode]); const handlePointerMove = useCallback( (event: ReactPointerEvent) => { // Update cursor const point = getCanvasPoint(event); if (point && cursorRef.current) { - cursorRef.current.style.transform = `translate(${point.x - brushSize / 2}px, ${point.y - brushSize / 2}px)`; - cursorRef.current.style.width = `${brushSize}px`; - cursorRef.current.style.height = `${brushSize}px`; + const showCursor = brushMode === "brush" || brushMode === "eraser"; + if (showCursor) { + cursorRef.current.style.opacity = "1"; + cursorRef.current.style.transform = `translate(${point.x - brushSize / 2}px, ${point.y - brushSize / 2}px)`; + cursorRef.current.style.width = `${brushSize}px`; + cursorRef.current.style.height = `${brushSize}px`; + } else { + cursorRef.current.style.opacity = "0"; + } } if (!pointerDownRef.current) return; if (!point || !lastPointRef.current) return; - drawStroke(lastPointRef.current, point); + if (isShapeMode(brushMode) && shapeStartPointRef.current) { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (ctx && shapeSnapshotRef.current) { + ctx.putImageData(shapeSnapshotRef.current, 0, 0); + drawShape(shapeStartPointRef.current, point); + } + } else { + drawStroke(lastPointRef.current, point); + } lastPointRef.current = point; scheduleFlush(); }, - [drawStroke, getCanvasPoint, scheduleFlush, brushSize] + [drawShape, drawStroke, getCanvasPoint, scheduleFlush, brushMode, brushSize] ); const handlePointerUp = useCallback( (event: ReactPointerEvent) => { pointerDownRef.current = false; + const endPoint = getCanvasPoint(event); + const shapeStart = shapeStartPointRef.current; + if (isShapeMode(brushMode) && endPoint && shapeStart) { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (ctx && shapeSnapshotRef.current) { + ctx.putImageData(shapeSnapshotRef.current, 0, 0); + drawShape(shapeStart, endPoint); + } + } lastPointRef.current = null; + shapeStartPointRef.current = null; + shapeSnapshotRef.current = null; try { event.currentTarget.releasePointerCapture(event.pointerId); } catch { // ignore } saveHistory(); // Save state after stroke - scheduleFlush(true); + scheduleFlush(true, false); }, - [scheduleFlush, saveHistory] + [brushMode, drawShape, getCanvasPoint, saveHistory, scheduleFlush] ); // Shortcuts @@ -570,7 +666,7 @@ export function RealtimeCanvasDialog({ if (!open) return; initializeCanvas(); void drawInitialImage().then(() => { - scheduleFlush(true); + scheduleFlush(true, false); }); const onResize = () => initializeCanvas(); window.addEventListener("resize", onResize); @@ -588,10 +684,29 @@ export function RealtimeCanvasDialog({ const start = async () => { try { - const configResponse = await fetch("/api/realtime/config", { cache: "no-store" }); - const configJson = (await configResponse.json().catch(() => ({}))) as Partial; - if (!configResponse.ok) { - throw new Error((configJson as any)?.message || "无法加载实时配置"); + let configResponse: Response | null = null; + let configJson: Partial & { message?: string; retryAfter?: number } = {}; + + for (let attempt = 0; attempt < 3; attempt += 1) { + configResponse = await fetch("/api/realtime/config", { cache: "no-store" }); + configJson = (await configResponse.json().catch(() => ({}))) as Partial & { + message?: string; + retryAfter?: number; + }; + if (configResponse.ok) break; + + const retryAfterSeconds = Number(configJson.retryAfter || 0); + const shouldRetry429 = configResponse.status === 429 && attempt < 2; + if (!shouldRetry429) { + throw new Error(configJson.message || "无法加载实时配置"); + } + + const retryDelay = retryAfterSeconds > 0 ? retryAfterSeconds * 1000 : 700 * (attempt + 1); + await new Promise((resolve) => window.setTimeout(resolve, retryDelay)); + } + + if (!configResponse?.ok) { + throw new Error(configJson.message || "无法加载实时配置"); } if (cancelled) return; @@ -693,7 +808,7 @@ export function RealtimeCanvasDialog({ connectionRef.current = connection; setStatus("connecting"); setStatusMessage("连接成功,等待首帧..."); - scheduleFlush(true); + scheduleFlush(true, false); } catch (error) { console.error("[RealtimeCanvas] init failed:", error); setStatus("error"); @@ -734,12 +849,65 @@ export function RealtimeCanvasDialog({ const clearSketch = useCallback(() => { resetCanvas(); dirtyRef.current = true; - scheduleFlush(true); + scheduleFlush(true, false); }, [resetCanvas, scheduleFlush]); + const handleSketchUpload = useCallback( + async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + if (!file.type.startsWith("image/")) { + toast.error("请上传图片文件"); + event.currentTarget.value = ""; + return; + } + + try { + const objectUrl = URL.createObjectURL(file); + const image = new Image(); + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error("图片加载失败")); + image.src = objectUrl; + }); + + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (!canvas || !ctx) { + URL.revokeObjectURL(objectUrl); + throw new Error("画布未初始化"); + } + + const rect = canvas.getBoundingClientRect(); + const canvasWidth = Math.max(1, rect.width); + const canvasHeight = Math.max(1, rect.height); + const scale = Math.min(canvasWidth / image.width, canvasHeight / image.height); + const drawWidth = image.width * scale; + const drawHeight = image.height * scale; + const offsetX = (canvasWidth - drawWidth) / 2; + const offsetY = (canvasHeight - drawHeight) / 2; + + ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight); + URL.revokeObjectURL(objectUrl); + saveHistory(); + scheduleFlush(true); + toast.success("图片已导入绘画区"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "导入图片失败"); + } finally { + event.currentTarget.value = ""; + } + }, + [saveHistory, scheduleFlush] + ); + return ( + + 实时画布 + 左侧绘制草图,右侧实时预览,并可将结果发送到无限画布。 + {/* Top Header - Minimal */}
@@ -773,7 +941,10 @@ export function RealtimeCanvasDialog({
setBrushMode("eraser")} + title="Eraser" > + +
@@ -890,9 +1080,26 @@ export function RealtimeCanvasDialog({ variant="ghost" className="h-10 w-10 rounded-xl hover:bg-destructive/10 hover:text-destructive" onClick={clearSketch} + title="Clear" > + +
@@ -906,7 +1113,7 @@ export function RealtimeCanvasDialog({ setPrompt(e.target.value)} - onBlur={() => scheduleFlush(true)} + onBlur={() => scheduleFlush(true, false)} placeholder="Describe what you want to create..." className="flex-1 h-10 border-0 bg-transparent focus-visible:ring-0 px-2 text-base shadow-none" /> diff --git a/components/home/lovart-home.tsx b/components/home/lovart-home.tsx index d0708983d..d03903c86 100644 --- a/components/home/lovart-home.tsx +++ b/components/home/lovart-home.tsx @@ -1504,7 +1504,11 @@ export function LovartHome({ - +
{siteSettingsData?.toolbox1Name || "工具箱"}
@@ -1535,7 +1539,11 @@ export function LovartHome({ - +
{siteSettingsData?.toolbox2Name || "工具箱2"}
diff --git a/lib/security/rate-limiter.ts b/lib/security/rate-limiter.ts index ad9c275d4..297488ea3 100644 --- a/lib/security/rate-limiter.ts +++ b/lib/security/rate-limiter.ts @@ -77,6 +77,12 @@ export const rateLimiters = { // 通用API限制:每分钟100次请求 api: new RateLimiter(60000, 100), + // 画布保存接口:每分钟300次请求(含自动保存) + canvas: new RateLimiter(60000, 300), + + // 实时画布相关接口:每分钟600次请求(配置/指标/代理) + realtime: new RateLimiter(60000, 600), + // 聊天接口限制:每分钟60次请求(避免被高频轮询接口挤占额度) chat: new RateLimiter(60000, 60), diff --git a/proxy.ts b/proxy.ts index ce2b7bba9..894cc94cc 100644 --- a/proxy.ts +++ b/proxy.ts @@ -40,6 +40,33 @@ export async function proxy(request: NextRequest) { { status: 429 } ); } + } else if (pathname.startsWith("/api/canvas/") && request.method === "PUT") { + const canvasRateLimit = checkRateLimit(request, rateLimiters.canvas); + if (!canvasRateLimit.allowed) { + console.warn(`[Security] Canvas rate limit exceeded for IP: ${clientIP}`, { + pathname, + resetTime: new Date(canvasRateLimit.resetTime).toISOString(), + }); + return NextResponse.json( + { error: "Too many canvas requests", retryAfter: Math.ceil((canvasRateLimit.resetTime - Date.now()) / 1000) }, + { status: 429 } + ); + } + } else if ( + pathname.startsWith("/api/realtime/") || + pathname.startsWith("/api/fal/proxy") + ) { + const realtimeRateLimit = checkRateLimit(request, rateLimiters.realtime); + if (!realtimeRateLimit.allowed) { + console.warn(`[Security] Realtime rate limit exceeded for IP: ${clientIP}`, { + pathname, + resetTime: new Date(realtimeRateLimit.resetTime).toISOString(), + }); + return NextResponse.json( + { error: "Too many realtime requests", retryAfter: Math.ceil((realtimeRateLimit.resetTime - Date.now()) / 1000) }, + { status: 429 } + ); + } } else if (pathname.startsWith("/api/")) { const rateLimit = checkRateLimit(request, rateLimiters.api); if (!rateLimit.allowed) { diff --git a/services/canvas/index.ts b/services/canvas/index.ts index 27f23f673..dcbe269b7 100644 --- a/services/canvas/index.ts +++ b/services/canvas/index.ts @@ -91,13 +91,28 @@ export const canvasService = { // 保存画布状态 async saveCanvasState(id: string, canvasData: CanvasState): Promise { try { - const response = await fetch(`/api/canvas/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ canvasData }), - }); - const data = await response.json(); - return data; + const requestBody = JSON.stringify({ canvasData }); + const doSave = async () => + fetch(`/api/canvas/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: requestBody, + }); + + let response = await doSave(); + if (response.status === 429) { + const retryPayload = await response.json().catch(() => ({})); + const retryAfter = Number(retryPayload?.retryAfter || 0); + const waitMs = retryAfter > 0 ? retryAfter * 1000 : 800; + await new Promise((resolve) => setTimeout(resolve, waitMs)); + response = await doSave(); + } + + const data = await response.json().catch(() => ({})); + if (!response.ok) { + return { success: false, error: String(data?.error || data?.message || '保存画布状态失败') }; + } + return data as ApiResponse; } catch (error) { return { success: false, error: '保存画布状态失败' }; }