fix: improve detail planning and realtime canvas behavior
This commit is contained in:
parent
4eabe6e583
commit
071958bb15
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string>();
|
||||
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) &&
|
||||
|
||||
@ -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<number | null>(null);
|
||||
const [detailSlotPickerIndex, setDetailSlotPickerIndex] = useState<number | null>(null);
|
||||
const [detailArchives, setDetailArchives] = useState<DetailArchive[]>([]);
|
||||
const [deletingDetailArchiveId, setDeletingDetailArchiveId] = useState<string | null>(null);
|
||||
const [detailBrief, setDetailBrief] = useState<DetailBriefForm>({
|
||||
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
|
||||
<span>{archive.imageCount || 0}张输出</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md border border-border/50 bg-secondary/50 px-2.5 py-1 text-[10px] font-medium text-secondary-foreground transition-colors hover:bg-primary hover:text-primary-foreground"
|
||||
onClick={() => loadDetailArchive(archive)}
|
||||
>
|
||||
加载
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-border/50 bg-secondary/50 px-2.5 py-1 text-[10px] font-medium text-secondary-foreground transition-colors hover:bg-primary hover:text-primary-foreground"
|
||||
onClick={() => loadDetailArchive(archive)}
|
||||
>
|
||||
加载
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-border/50 bg-background text-muted-foreground transition-colors hover:border-destructive/40 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => void deleteDetailArchive(archive.id)}
|
||||
disabled={deletingDetailArchiveId === archive.id}
|
||||
title="删除存档"
|
||||
aria-label="删除存档"
|
||||
>
|
||||
{deletingDetailArchiveId === archive.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -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<any>(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<ImageData | null>(null);
|
||||
const pendingFlushTimerRef = useRef<number | null>(null);
|
||||
const flushInFlightRef = useRef(false);
|
||||
const dirtyRef = useRef(false);
|
||||
@ -165,6 +183,7 @@ export function RealtimeCanvasDialog({
|
||||
const historyRef = useRef<ImageData[]>([]);
|
||||
const historyIndexRef = useRef(-1);
|
||||
const cursorRef = useRef<HTMLDivElement>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement | null>(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<number>(
|
||||
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<HTMLCanvasElement>) => {
|
||||
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<HTMLCanvasElement>) => {
|
||||
// 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<HTMLCanvasElement>) => {
|
||||
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<RealtimeConfig>;
|
||||
if (!configResponse.ok) {
|
||||
throw new Error((configJson as any)?.message || "无法加载实时配置");
|
||||
let configResponse: Response | null = null;
|
||||
let configJson: Partial<RealtimeConfig> & { 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<RealtimeConfig> & {
|
||||
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<HTMLInputElement>) => {
|
||||
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<void>((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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="h-[95vh] w-[98vw] max-w-[1800px] overflow-hidden rounded-[20px] border-0 bg-background p-0 shadow-2xl sm:rounded-[20px] md:rounded-[24px]">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>实时画布</DialogTitle>
|
||||
<DialogDescription>左侧绘制草图,右侧实时预览,并可将结果发送到无限画布。</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Top Header - Minimal */}
|
||||
<div className="absolute left-0 right-0 top-0 z-50 flex h-14 items-center justify-between px-6 bg-gradient-to-b from-black/20 to-transparent pointer-events-none">
|
||||
<div className="pointer-events-auto flex items-center gap-3 rounded-full bg-background/80 backdrop-blur-md px-4 py-1.5 border border-border/50 shadow-sm">
|
||||
@ -773,7 +941,10 @@ export function RealtimeCanvasDialog({
|
||||
<div className="relative">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="touch-none shadow-sm block cursor-none"
|
||||
className={cn(
|
||||
"touch-none shadow-sm block",
|
||||
brushMode === "brush" || brushMode === "eraser" ? "cursor-none" : "cursor-crosshair"
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
@ -866,9 +1037,28 @@ export function RealtimeCanvasDialog({
|
||||
variant={brushMode === "eraser" ? "secondary" : "ghost"}
|
||||
className={cn("h-10 w-10 rounded-xl transition-all", brushMode === "eraser" && "bg-primary text-primary-foreground shadow-sm")}
|
||||
onClick={() => setBrushMode("eraser")}
|
||||
title="Eraser"
|
||||
>
|
||||
<Eraser className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={brushMode === "rect" ? "secondary" : "ghost"}
|
||||
className={cn("h-10 w-10 rounded-xl transition-all", brushMode === "rect" && "bg-primary text-primary-foreground shadow-sm")}
|
||||
onClick={() => setBrushMode("rect")}
|
||||
title="Rectangle"
|
||||
>
|
||||
<Square className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={brushMode === "circle" ? "secondary" : "ghost"}
|
||||
className={cn("h-10 w-10 rounded-xl transition-all", brushMode === "circle" && "bg-primary text-primary-foreground shadow-sm")}
|
||||
onClick={() => setBrushMode("circle")}
|
||||
title="Circle"
|
||||
>
|
||||
<CircleIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="h-[1px] bg-border/50 mx-2 my-1" />
|
||||
|
||||
<div className="flex flex-col items-center gap-2 py-2">
|
||||
@ -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"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-10 w-10 rounded-xl hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
title="Upload Image"
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
</Button>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleSketchUpload}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -906,7 +1113,7 @@ export function RealtimeCanvasDialog({
|
||||
<Input
|
||||
value={prompt}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
|
||||
@ -1504,7 +1504,11 @@ export function LovartHome({
|
||||
<Briefcase className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="right" align="start" className="w-[248px] rounded-2xl p-2">
|
||||
<HoverCardContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="w-[248px] rounded-2xl border-black/10 bg-[#f4f4f4]/90 p-2 backdrop-blur-md shadow-[0_16px_40px_rgba(0,0,0,0.12)]"
|
||||
>
|
||||
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/50">
|
||||
{siteSettingsData?.toolbox1Name || "工具箱"}
|
||||
</div>
|
||||
@ -1535,7 +1539,11 @@ export function LovartHome({
|
||||
<Briefcase className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="right" align="start" className="w-[248px] rounded-2xl p-2">
|
||||
<HoverCardContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="w-[248px] rounded-2xl border-black/10 bg-[#f4f4f4]/90 p-2 backdrop-blur-md shadow-[0_16px_40px_rgba(0,0,0,0.12)]"
|
||||
>
|
||||
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/50">
|
||||
{siteSettingsData?.toolbox2Name || "工具箱2"}
|
||||
</div>
|
||||
|
||||
@ -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),
|
||||
|
||||
|
||||
27
proxy.ts
27
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) {
|
||||
|
||||
@ -91,13 +91,28 @@ export const canvasService = {
|
||||
// 保存画布状态
|
||||
async saveCanvasState(id: string, canvasData: CanvasState): Promise<ApiResponse> {
|
||||
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: '保存画布状态失败' };
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user