feat: refine detail generation copy strategy and canvas layout
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-22 04:08:59 +08:00
parent 080328fbeb
commit 0fa4a14ac6
5 changed files with 1129 additions and 800 deletions

View File

@ -179,6 +179,25 @@ const AGENT_PLANNING_PHRASE_PATTERNS = [
/第一屏展示啥/gi, /第一屏展示啥/gi,
]; ];
const TITLE_META_TERMS = [
"核心价值",
"场景痛点",
"方案拆解",
"细节证明",
"信任收口",
"卖点延展",
"解决方案",
"价值延展",
"证据背书",
];
function hasPlanningTitleMeta(value: string): boolean {
const text = String(value || "").trim();
if (!text) return false;
if (/第\s*\d+\s*屏|screen\s*\d+/i.test(text)) return true;
return TITLE_META_TERMS.some((term) => text.includes(term));
}
function stripAgentPlanningPhrases(value: string): string { function stripAgentPlanningPhrases(value: string): string {
let result = value; let result = value;
for (const pattern of AGENT_PLANNING_PHRASE_PATTERNS) { for (const pattern of AGENT_PLANNING_PHRASE_PATTERNS) {
@ -187,6 +206,49 @@ function stripAgentPlanningPhrases(value: string): string {
return result.replace(/\s+/g, " ").trim(); return result.replace(/\s+/g, " ").trim();
} }
function desensitizeAudienceCopy(value: string, language: "zh" | "en"): string {
let result = String(value || "");
if (!result) return result;
if (language === "en") {
result = result
.replace(/\b\d{1,2}\s*(?:-|~|to)\s*\d{1,2}\s*years?\s*old\b/gi, "target users")
.replace(
/\b(?:for|toward|targeting)\s+[^,.;\n]{0,48}\b(?:women|men|female|male|audience|users?)\b/gi,
"for target scenario users"
)
.replace(/\b(?:primary audience|target audience)\s*:\s*[^,.;\n]+/gi, "target scenario users");
} else {
result = result
.replace(/\d{1,2}\s*(?:-|~|~|到|至)\s*\d{1,2}\s*岁(?:[男女]性)?/g, "目标用户")
.replace(
/(?:面向|针对)[^,。;\n]{0,40}(?:人群|用户|女性|男性|宝妈|白领|学生|新手)/g,
"围绕目标场景用户"
)
.replace(
/(?:主购买人群|目标人群|适用人群)\s*[::]\s*[^,。;\n]+/g,
"目标场景用户"
);
}
return result.replace(/\s+/g, " ").replace(/,{2,}/g, ",").trim();
}
const GENERIC_FLUFF_WORDS_ZH = [
"高级感",
"轻奢",
"超好用",
"巨能装",
"适合所有场景",
"适合所有人",
];
function visibleTitleLength(value: string): number {
return value
.replace(/[^\p{L}\p{N}]+/gu, "")
.trim().length;
}
function buildTypographySpec(language: "zh" | "en") { function buildTypographySpec(language: "zh" | "en") {
if (language === "en") { if (language === "en") {
return { return {
@ -448,8 +510,8 @@ function buildFallbackScreens(params: {
const screenTypeSequence: DetailScreenType[] = ["B", "A", "B", "C", "B"]; const screenTypeSequence: DetailScreenType[] = ["B", "A", "B", "C", "B"];
const defaultTitles = const defaultTitles =
language === "en" language === "en"
? ["Core Value", "Pain Resonance", "Solution Logic", "Detail Proof", "Trust Closure"] ? ["Long-Sit Comfort", "Waist Support", "One-Step Adjust", "Stable Structure", "Verified Trust"]
: ["核心价值", "场景痛点", "方案拆解", "细节证明", "信任收口"]; : ["久坐不累", "贴腰承托", "一键调节", "稳固支撑", "安心可证"];
return Array.from({ length: count }).map((_, index) => { return Array.from({ length: count }).map((_, index) => {
const screenType = const screenType =
@ -466,16 +528,16 @@ function buildFallbackScreens(params: {
? "Bundle Cross-sell" ? "Bundle Cross-sell"
: "搭配推荐" : "搭配推荐"
: language === "en" : language === "en"
? defaultTitles[index] || `Selling Point Extension ${index + 1}` ? defaultTitles[index] || `Comfort Upgrade ${index + 1}`
: defaultTitles[index] || "卖点延展"; : defaultTitles[index] || "舒适升级";
const subtitle = const subtitle =
isBundleScreen isBundleScreen
? language === "en" ? language === "en"
? `Show product pairing with ${bundleImageCount} optional companion SKU(s) to increase basket size.` ? `Show product pairing with ${bundleImageCount} optional companion SKU(s) to increase basket size.`
: `联动${bundleImageCount}张搭配产品参考图,强化关联销售与加购动机。` : `联动${bundleImageCount}张搭配产品参考图,强化关联销售与加购动机。`
: language === "en" : language === "en"
? `For ${brief.targetAudience}, solve "${coreNeed}" in ${mainScenario}.` ? `Solve "${coreNeed}" in ${mainScenario} with clear benefit-focused messaging.`
: `面向${brief.targetAudience},在${mainScenario}场景解决“${coreNeed}”。`; : `在${mainScenario}场景解决“${coreNeed}”,突出结果导向表达。`;
const copy = const copy =
isBundleScreen isBundleScreen
? language === "en" ? language === "en"
@ -529,7 +591,8 @@ function buildFallbackScreens(params: {
`E-commerce detail frame ${index + 1}, objective: ${objective}.`, `E-commerce detail frame ${index + 1}, objective: ${objective}.`,
`Fixed vertical canvas 790x1300 pixels.`, `Fixed vertical canvas 790x1300 pixels.`,
`Keep exact product identity consistent across all frames.`, `Keep exact product identity consistent across all frames.`,
`Main scene: ${mainScenario}; audience: ${brief.targetAudience}; core demand: ${coreNeed}.`, `Main scene: ${mainScenario}; core demand: ${coreNeed}.`,
"Audience profile is internal strategy only; do not expose explicit age/gender labels in visible copy.",
`Positioning statement: ${positioning}.`, `Positioning statement: ${positioning}.`,
`Global style: ${globalStyle.designLanguage}.`, `Global style: ${globalStyle.designLanguage}.`,
`Typography: ${globalStyle.typography.fontFamily}; ${globalStyle.typography.title}; ${globalStyle.typography.subtitle}; ${globalStyle.typography.body}.`, `Typography: ${globalStyle.typography.fontFamily}; ${globalStyle.typography.title}; ${globalStyle.typography.subtitle}; ${globalStyle.typography.body}.`,
@ -551,7 +614,8 @@ function buildFallbackScreens(params: {
`电商详情页第${index + 1}屏,目标:${objective}。`, `电商详情页第${index + 1}屏,目标:${objective}。`,
"画布固定竖版 790x1300 像素。", "画布固定竖版 790x1300 像素。",
"全套保持同一产品外观一致(结构、材质、颜色、比例、Logo)。", "全套保持同一产品外观一致(结构、材质、颜色、比例、Logo)。",
`主场景:${mainScenario};人群:${brief.targetAudience};核心需求:${coreNeed}。`, `主场景:${mainScenario};核心需求:${coreNeed}。`,
"人群画像仅用于内部策略,画面可见文案不得直写年龄/性别标签。",
`一句话定位:${positioning}。`, `一句话定位:${positioning}。`,
`全局风格:${globalStyle.designLanguage}。`, `全局风格:${globalStyle.designLanguage}。`,
`字体规范:${globalStyle.typography.fontFamily};${globalStyle.typography.title};${globalStyle.typography.subtitle};${globalStyle.typography.body}。`, `字体规范:${globalStyle.typography.fontFamily};${globalStyle.typography.title};${globalStyle.typography.subtitle};${globalStyle.typography.body}。`,
@ -627,7 +691,8 @@ function buildFallbackPlan(params: {
function sanitizeScreen( function sanitizeScreen(
raw: unknown, raw: unknown,
fallback: DetailPlanScreen fallback: DetailPlanScreen,
language: "zh" | "en"
): DetailPlanScreen { ): DetailPlanScreen {
const source = (raw || {}) as Record<string, unknown>; const source = (raw || {}) as Record<string, unknown>;
const safeText = (value: unknown, defaultValue: string) => { const safeText = (value: unknown, defaultValue: string) => {
@ -636,7 +701,10 @@ function sanitizeScreen(
}; };
const fabeSource = (source.fabe || {}) as Record<string, unknown>; const fabeSource = (source.fabe || {}) as Record<string, unknown>;
const visualPrompt = safeText(source.visualPrompt, fallback.visualPrompt); const visualPrompt = desensitizeAudienceCopy(
safeText(source.visualPrompt, fallback.visualPrompt),
language
);
const normalizeTitle = (value: string) => { const normalizeTitle = (value: string) => {
const cleaned = stripAgentPlanningPhrases( const cleaned = stripAgentPlanningPhrases(
value value
@ -646,7 +714,9 @@ function sanitizeScreen(
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim() .trim()
); );
return cleaned || fallback.title; if (!cleaned) return fallback.title;
if (hasPlanningTitleMeta(cleaned)) return fallback.title;
return cleaned;
}; };
const normalizeSubtitle = (value: string) => const normalizeSubtitle = (value: string) =>
stripAgentPlanningPhrases( stripAgentPlanningPhrases(
@ -656,9 +726,10 @@ function sanitizeScreen(
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim() .trim()
); );
const normalizeCopy = (value: string) => stripAgentPlanningPhrases(value) || fallback.copy; const normalizeCopy = (value: string) =>
desensitizeAudienceCopy(stripAgentPlanningPhrases(value) || fallback.copy, language);
const normalizeLayoutNote = (value: string) => const normalizeLayoutNote = (value: string) =>
stripAgentPlanningPhrases(value) || fallback.layoutNote; desensitizeAudienceCopy(stripAgentPlanningPhrases(value) || fallback.layoutNote, language);
const rawScreenType = String(source.screenType || "").trim().toUpperCase(); const rawScreenType = String(source.screenType || "").trim().toUpperCase();
const screenType: DetailScreenType = const screenType: DetailScreenType =
rawScreenType === "A" || rawScreenType === "B" || rawScreenType === "C" rawScreenType === "A" || rawScreenType === "B" || rawScreenType === "C"
@ -667,19 +738,23 @@ function sanitizeScreen(
return { return {
index: fallback.index, index: fallback.index,
screenType, screenType,
objective: safeText(source.objective, fallback.objective), objective: desensitizeAudienceCopy(safeText(source.objective, fallback.objective), language),
title: normalizeTitle(safeText(source.title, fallback.title)), title: desensitizeAudienceCopy(normalizeTitle(safeText(source.title, fallback.title)), language),
subtitle: normalizeSubtitle(safeText(source.subtitle, fallback.subtitle)) || fallback.subtitle, subtitle:
desensitizeAudienceCopy(
normalizeSubtitle(safeText(source.subtitle, fallback.subtitle)) || fallback.subtitle,
language
) || fallback.subtitle,
copy: normalizeCopy(safeText(source.copy, fallback.copy)), copy: normalizeCopy(safeText(source.copy, fallback.copy)),
layoutNote: normalizeLayoutNote(safeText(source.layoutNote, fallback.layoutNote)), layoutNote: normalizeLayoutNote(safeText(source.layoutNote, fallback.layoutNote)),
useModel: useModel:
typeof source.useModel === "boolean" ? source.useModel : fallback.useModel, typeof source.useModel === "boolean" ? source.useModel : fallback.useModel,
modelReason: safeText(source.modelReason, fallback.modelReason), modelReason: desensitizeAudienceCopy(safeText(source.modelReason, fallback.modelReason), language),
bundleShowcase: bundleShowcase:
typeof source.bundleShowcase === "boolean" typeof source.bundleShowcase === "boolean"
? source.bundleShowcase ? source.bundleShowcase
: fallback.bundleShowcase, : fallback.bundleShowcase,
bundleReason: safeText(source.bundleReason, fallback.bundleReason), bundleReason: desensitizeAudienceCopy(safeText(source.bundleReason, fallback.bundleReason), language),
visualPrompt: visualPrompt.includes("790x1300") visualPrompt: visualPrompt.includes("790x1300")
? visualPrompt ? visualPrompt
: `${visualPrompt} 画布固定 790x1300 像素。`, : `${visualPrompt} 画布固定 790x1300 像素。`,
@ -695,11 +770,12 @@ function sanitizeScreen(
function sanitizePlannedScreens( function sanitizePlannedScreens(
raw: unknown, raw: unknown,
fallbackScreens: DetailPlanScreen[], fallbackScreens: DetailPlanScreen[],
language: "zh" | "en"
): DetailPlanScreen[] { ): DetailPlanScreen[] {
if (!Array.isArray(raw)) { if (!Array.isArray(raw)) {
return fallbackScreens; return fallbackScreens;
} }
return fallbackScreens.map((fallback, index) => sanitizeScreen(raw[index], fallback)); return fallbackScreens.map((fallback, index) => sanitizeScreen(raw[index], fallback, language));
} }
function sanitizeGlobalStyle( function sanitizeGlobalStyle(
@ -781,9 +857,33 @@ function evaluatePlanQuality(params: {
issues.push("存在分镜FABE不完整。"); issues.push("存在分镜FABE不完整。");
} }
const hasScreenIndexInTitle = screens.some((screen) => /第\s*\d+\s*屏|screen\s*\d+/i.test(screen.title)); const hasMetaTitle = screens.some((screen) => hasPlanningTitleMeta(screen.title));
if (hasScreenIndexInTitle) { if (hasMetaTitle) {
issues.push("主标题不应出现“第X屏/Screen X”类标记。"); issues.push("主标题不应使用规划节奏词(如“方案拆解/细节证明/信任收口”等)。");
}
const titleLengthOutOfRange = screens.some((screen) => {
const len = visibleTitleLength(screen.title);
return len < 4 || len > 10;
});
if (titleLengthOutOfRange) {
issues.push("存在分镜主标题不在4-10字区间。");
}
const subtitleTooLong = screens.some((screen) => {
const normalized = screen.subtitle.replace(/\s+/g, "");
return normalized.length > 38 || /[。!?.!?].+[。!?.!?]/.test(screen.subtitle);
});
if (subtitleTooLong) {
issues.push("存在分镜副标题过长或多句,建议保持一条结果句。");
}
const hasGenericFluff = screens.some((screen) => {
const fullText = `${screen.title} ${screen.subtitle} ${screen.copy}`;
return GENERIC_FLUFF_WORDS_ZH.some((word) => fullText.includes(word));
});
if (hasGenericFluff) {
issues.push("存在空泛形容词(如“高级感/轻奢/超好用”),需改为可感知结果。");
} }
if (hasModelImage) { if (hasModelImage) {
@ -855,6 +955,8 @@ async function runDualDirectorPlanPreflight(params: {
"Role A: Art Director (visual hierarchy, typography, spacing rhythm, composition).", "Role A: Art Director (visual hierarchy, typography, spacing rhythm, composition).",
"Role B: Marketing Director (pain point to benefit logic, trust proof, conversion clarity).", "Role B: Marketing Director (pain point to benefit logic, trust proof, conversion clarity).",
"Forbidden wording in title/subtitle/copy: \"Screen X\", \"This screen shows\", any planning meta phrasing.", "Forbidden wording in title/subtitle/copy: \"Screen X\", \"This screen shows\", any planning meta phrasing.",
"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.", "Must keep canvas 790x1300, unified typography hierarchy, and consistent style.",
params.hasModelImage params.hasModelImage
? "Model image is provided: only use where needed, keep identity consistent." ? "Model image is provided: only use where needed, keep identity consistent."
@ -875,6 +977,8 @@ async function runDualDirectorPlanPreflight(params: {
"角色A:平面设计总监(视觉层级、字体统一、留白节奏、构图平衡)。", "角色A:平面设计总监(视觉层级、字体统一、留白节奏、构图平衡)。",
"角色B:营销总监(痛点到买点转译、证据可信度、转化路径清晰度)。", "角色B:营销总监(痛点到买点转译、证据可信度、转化路径清晰度)。",
"禁用表达:主标题/副标题/正文中禁止出现“第X屏”“本屏展示”“这一屏”等规划词。", "禁用表达:主标题/副标题/正文中禁止出现“第X屏”“本屏展示”“这一屏”等规划词。",
"禁用表达:主标题禁止出现“核心价值/场景痛点/方案拆解/细节证明/信任收口/卖点延展”等节奏词。",
"禁用表达:对外文案不得直写具体年龄/性别标签(如“25-35岁女性”),必须改为场景化表达。",
"必须维持 790x1300 固定规格、全套统一字体层级与统一风格。", "必须维持 790x1300 固定规格、全套统一字体层级与统一风格。",
params.hasModelImage params.hasModelImage
? "已提供模特图:仅在必要分镜使用且保持同一人物一致性。" ? "已提供模特图:仅在必要分镜使用且保持同一人物一致性。"
@ -940,7 +1044,7 @@ async function runDualDirectorPlanPreflight(params: {
rewriteApplied = true; rewriteApplied = true;
} }
if (Array.isArray(parsed?.revisedScreens) && parsed.revisedScreens.length > 0) { if (Array.isArray(parsed?.revisedScreens) && parsed.revisedScreens.length > 0) {
candidateScreens = sanitizePlannedScreens(parsed.revisedScreens, params.screens); candidateScreens = sanitizePlannedScreens(parsed.revisedScreens, params.screens, params.language);
rewriteApplied = true; rewriteApplied = true;
} }
@ -1099,16 +1203,23 @@ export async function POST(request: Request) {
`5) 结构遵循“黄金五屏原则”:屏1-2核心卖点,屏3-4差异强化,屏5证据背书;更多屏作为延展。\n` + `5) 结构遵循“黄金五屏原则”:屏1-2核心卖点,屏3-4差异强化,屏5证据背书;更多屏作为延展。\n` +
`6) 每屏都要有 FABE 闭环(B利益/F特征/A优势/E证据),且文案符合电商转化表达。\n` + `6) 每屏都要有 FABE 闭环(B利益/F特征/A优势/E证据),且文案符合电商转化表达。\n` +
`7) 文案用词需贴近用户语言,不写空泛形容词,不泄露系统流程。\n` + `7) 文案用词需贴近用户语言,不写空泛形容词,不泄露系统流程。\n` +
`7.1) 必须执行“卖点→用户任务(JTBD)→买点句”转译,买点句优先用“不用___也能___ / 在___场景也能___”句式。\n` +
`7.2) 核心需求最多保留3-5条(1条必备 + 1-2条期待 + 1条魅力),避免页面过载。\n` +
`7.3) 每个核心卖点需覆盖“体验/社交/实用”三维中的至少2维,并落地到 copy 或 layoutNote。\n` +
`8) 对每屏输出 useModel 与 modelReason:useModel=true 表示该屏必须融入固定模特图人物;false 表示该屏禁止出现人物。\n` + `8) 对每屏输出 useModel 与 modelReason:useModel=true 表示该屏必须融入固定模特图人物;false 表示该屏禁止出现人物。\n` +
`9) 对每屏输出 screenType(A/B/C):A=技术公信,B=场景共鸣,C=细节佐证。\n` + `9) 对每屏输出 screenType(A/B/C):A=技术公信,B=场景共鸣,C=细节佐证。\n` +
`9.1) 前3屏默认节奏:屏1=场景痛点共鸣(B),屏2=买点总括,屏3=证据验证(对比/实测/口碑/认证)。\n` +
`10) 主标题必须是卖点语义,不得出现“第1屏/Screen 1”等编号标识,建议4-10字。\n` + `10) 主标题必须是卖点语义,不得出现“第1屏/Screen 1”等编号标识,建议4-10字。\n` +
`10.1) 主标题禁止使用规划节奏词:核心价值/场景痛点/方案拆解/细节证明/信任收口/卖点延展。\n` +
`11) 在 globalStyle.typography 中写明明确字号与字重(示例:主标题64px/700,副标题34px/600,正文24px/500),并在每屏 layoutNote 里落实排版层级。\n` + `11) 在 globalStyle.typography 中写明明确字号与字重(示例:主标题64px/700,副标题34px/600,正文24px/500),并在每屏 layoutNote 里落实排版层级。\n` +
`11.1) 副标题必须为单句结果句,禁止写成长段说明。\n` +
`12) 对外文案禁止直写具体年龄/性别标签(如“25-35岁女性”),改为场景化表达(如“久坐办公场景用户”)。\n` +
(hasModelImage (hasModelImage
? `12) 已提供模特图:涉及人物场景时必须复用同一模特(人脸、发型、体型、肤色与服装风格一致),不得替换其他人物。\n` ? `13) 已提供模特图:涉及人物场景时必须复用同一模特(人脸、发型、体型、肤色与服装风格一致),不得替换其他人物。\n`
: `12) 未提供模特图:所有分镜均设置 useModel=false。\n`) + : `13) 未提供模特图:所有分镜均设置 useModel=false。\n`) +
(hasBundleImages (hasBundleImages
? `13) 已提供搭配产品图:必须新增且仅新增1屏“场景化产品搭配展示”,该屏设定 bundleShowcase=true,并在 bundleReason 说明搭配策略;其余屏设为 bundleShowcase=false。\n` ? `14) 已提供搭配产品图:必须新增且仅新增1屏“场景化产品搭配展示”,该屏设定 bundleShowcase=true,并在 bundleReason 说明搭配策略;其余屏设为 bundleShowcase=false。\n`
: `13) 未提供搭配产品图:所有分镜均设置 bundleShowcase=false。\n`) + : `14) 未提供搭配产品图:所有分镜均设置 bundleShowcase=false。\n`) +
`输出格式:\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":"4-8字标题","subtitle":"一句副标题","copy":"正文文案","layoutNote":"排版说明","useModel":false,"modelReason":"是否需要人物及原因","bundleShowcase":false,"bundleReason":"是否为搭配展示页及原因","fabe":{"b":"利益","f":"特征","a":"优势","e":"证据"},"visualPrompt":"用于生图的完整提示词,必须包含790x1300与统一字体排版"}]}`,
temperature: 0.25, temperature: 0.25,
@ -1147,7 +1258,7 @@ export async function POST(request: Request) {
const parsed = extractJsonBlock<{ summary?: unknown; globalStyle?: unknown; screens?: unknown }>(text || ""); const parsed = extractJsonBlock<{ summary?: unknown; globalStyle?: unknown; screens?: unknown }>(text || "");
const globalStyleInitial = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle); const globalStyleInitial = sanitizeGlobalStyle(parsed?.globalStyle, fallback.globalStyle);
const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens).map((item) => const rawScreens = sanitizePlannedScreens(parsed?.screens, fallback.screens, language).map((item) =>
hasModelImage hasModelImage
? item ? item
: { : {
@ -1220,9 +1331,12 @@ export async function POST(request: Request) {
}; };
const prompts = screens.map((item) => item.visualPrompt); const prompts = screens.map((item) => item.visualPrompt);
const summaryRaw = String(parsed?.summary || "").trim(); const summaryRaw = String(parsed?.summary || "").trim();
const summary = !shouldFallback const summary = desensitizeAudienceCopy(
? summaryRaw || fallback.summary !shouldFallback
: `${summaryRaw || fallback.summary}\n双总监预审未通过,已自动回退到稳定分镜模板。`; ? summaryRaw || fallback.summary
: `${summaryRaw || fallback.summary}\n双总监预审未通过,已自动回退到稳定分镜模板。`,
language
);
const response: DetailPlanResponse = { const response: DetailPlanResponse = {
summary, summary,

View File

@ -98,52 +98,55 @@
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--color-background: var(--background); --animate-accordion-down: accordion-down 0.2s ease-out;
--color-foreground: var(--foreground); --animate-accordion-up: accordion-up 0.2s ease-out;
--animate-caret-blink: caret-blink 1.25s ease-out infinite;
--color-card: var(--card); @keyframes accordion-down {
--color-card-foreground: var(--card-foreground); from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
--color-popover: var(--popover); @keyframes accordion-up {
--color-popover-foreground: var(--popover-foreground); from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
--color-primary: var(--primary); @keyframes caret-blink {
--color-primary-foreground: var(--primary-foreground); 0%,
70%,
100% {
opacity: 1;
}
20%,
50% {
opacity: 0;
}
}
}
--color-secondary: var(--secondary); /* utility classes */
--color-secondary-foreground: var(--secondary-foreground); @utility container {
margin-inline: auto;
--color-muted: var(--muted); padding-inline: 2rem;
--color-muted-foreground: var(--muted-foreground); @media (width >= --theme(--breakpoint-sm)) {
max-width: none;
--color-accent: var(--accent); }
--color-accent-foreground: var(--accent-foreground); @media (width >= 1400px) {
max-width: 1400px;
--color-destructive: var(--destructive); }
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar-background);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
} }
/* /*
The default border color has changed to `currentcolor` in Tailwind CSS v4, The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3. looks the same as it did with Tailwind CSS v3.
@ -156,200 +159,24 @@
::before, ::before,
::backdrop, ::backdrop,
::file-selector-button { ::file-selector-button {
border-color: var(--color-gray-200, currentcolor); border-color: var(--border, currentColor);
} }
} }
@utility text-balance {
text-wrap: balance;
}
@utility -webkit-overflow-scrolling-touch {
-webkit-overflow-scrolling: touch;
}
@utility touch-pan-y {
touch-action: pan-y;
}
@utility overscroll-behavior-contain {
overscroll-behavior: contain;
}
@layer utilities { @layer utilities {
:root { .step {
--foreground-rgb: 0, 0, 0; counter-increment: step;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
} }
@media (prefers-color-scheme: dark) { .step:before {
:root { @apply absolute w-9 h-9 bg-muted rounded-full font-mono font-medium text-center text-base inline-flex items-center justify-center -indent-px border-4 border-background;
--foreground-rgb: 255, 255, 255; @apply ml-[-50px] mt-[-4px];
--background-start-rgb: 0, 0, 0; content: counter(step);
--background-end-rgb: 0, 0, 0;
}
}
}
@layer base {
* {
@apply border-border;
} }
body { .pattern-grid-lg {
@apply bg-background text-foreground; background-image: linear-gradient(to right, currentColor 1px, transparent 1px),
overflow-x: hidden; linear-gradient(to bottom, currentColor 1px, transparent 1px);
position: relative; background-size: 40px 40px;
}
html {
overflow-x: hidden;
}
}
.skeleton {
* {
pointer-events: none !important;
}
*[class^="text-"] {
color: transparent;
@apply rounded-md bg-foreground/20 select-none animate-pulse;
}
.skeleton-bg {
@apply bg-foreground/10;
}
.skeleton-div {
@apply bg-foreground/20 animate-pulse;
}
}
.ProseMirror {
outline: none;
}
.cm-editor,
.cm-gutters {
@apply bg-background! dark:bg-zinc-800! outline-hidden! selection:bg-zinc-900!;
}
.ͼo.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,
.ͼo.cm-selectionBackground,
.ͼo.cm-content::selection {
@apply bg-zinc-200! dark:bg-zinc-900!;
}
.cm-activeLine,
.cm-activeLineGutter {
@apply bg-transparent!;
}
.cm-activeLine {
@apply rounded-r-sm!;
}
.cm-lineNumbers {
@apply min-w-7;
}
.cm-foldGutter {
@apply min-w-3;
}
@layer base {
a[href],
summary,
button:not(:disabled),
label[for],
[role="button"],
[role="link"],
[role="menuitem"],
[role="option"],
[role="radio"],
[role="switch"],
[role="tab"],
[role="checkbox"],
[data-radix-collection-item],
input[type="checkbox"],
input[type="radio"],
input[type="range"] {
cursor: pointer;
}
button:disabled,
[aria-disabled="true"],
[data-disabled] {
cursor: not-allowed;
}
}
.cm-lineNumbers .cm-activeLineGutter {
@apply rounded-l-sm!;
}
.suggestion-highlight {
@apply bg-blue-200 hover:bg-blue-300 dark:hover:bg-blue-400/50 dark:text-blue-50 dark:bg-blue-500/40;
}
/* minimal scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
transition: background 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: --alpha(var(--muted-foreground) / 0.5);
}
::-webkit-scrollbar-corner {
background: transparent;
}
/* firefox scrollbar styling */
* {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
@theme inline {
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/* AI 思考动画 */
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
Eraser, Eraser,
@ -23,6 +23,14 @@ import {
Trash2, Trash2,
Wifi, Wifi,
WifiOff, WifiOff,
Minimize2,
Maximize2,
Download,
Image as ImageIcon,
MoreHorizontal,
Undo2,
Redo2,
X,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -152,6 +160,14 @@ export function RealtimeCanvasDialog({
const metricsQueueRef = useRef<any[]>([]); const metricsQueueRef = useRef<any[]>([]);
const sessionIdRef = useRef(`rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); const sessionIdRef = useRef(`rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
const hasLoadedInitialImageRef = useRef(false); const hasLoadedInitialImageRef = useRef(false);
// History Management
const historyRef = useRef<ImageData[]>([]);
const historyIndexRef = useRef(-1);
const cursorRef = useRef<HTMLDivElement>(null);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [config, setConfig] = useState<RealtimeConfig>(defaultRealtimeConfig); const [config, setConfig] = useState<RealtimeConfig>(defaultRealtimeConfig);
const [status, setStatus] = useState<RealtimeStatus>("idle"); const [status, setStatus] = useState<RealtimeStatus>("idle");
const [statusMessage, setStatusMessage] = useState("等待连接"); const [statusMessage, setStatusMessage] = useState("等待连接");
@ -198,44 +214,31 @@ export function RealtimeCanvasDialog({
} }
}, [open]); }, [open]);
const resetCanvas = useCallback(() => { const saveHistory = useCallback(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return; if (!ctx) return;
ctx.save();
ctx.globalCompositeOperation = "source-over"; // If we are not at the end of history, truncate future history
ctx.fillStyle = "#d8d8db"; if (historyIndexRef.current < historyRef.current.length - 1) {
ctx.fillRect(0, 0, canvas.width, canvas.height); historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1);
ctx.restore(); }
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
historyRef.current.push(imageData);
historyIndexRef.current = historyRef.current.length - 1;
// Limit history size
if (historyRef.current.length > 50) {
historyRef.current.shift();
historyIndexRef.current--;
}
setCanUndo(historyIndexRef.current > 0);
setCanRedo(false);
}, []); }, []);
const drawInitialImage = useCallback(async () => {
if (!open || !initialImageUrl || hasLoadedInitialImageRef.current) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const image = new Image();
image.crossOrigin = "anonymous";
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error("加载初始图片失败"));
image.src = initialImageUrl;
});
const scale = Math.min(canvas.width / image.width, canvas.height / image.height);
const drawWidth = image.width * scale;
const drawHeight = image.height * scale;
const offsetX = (canvas.width - drawWidth) / 2;
const offsetY = (canvas.height - drawHeight) / 2;
ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
hasLoadedInitialImageRef.current = true;
dirtyRef.current = true;
}, [initialImageUrl, open]);
const flushFrame = useCallback( const flushFrame = useCallback(
async (force = false) => { async (force = false) => {
if (!open) return; if (!open) return;
@ -309,6 +312,82 @@ export function RealtimeCanvasDialog({
[adaptiveThrottleMs, flushFrame] [adaptiveThrottleMs, flushFrame]
); );
const undo = useCallback(() => {
if (historyIndexRef.current <= 0) return;
historyIndexRef.current--;
const imageData = historyRef.current[historyIndexRef.current];
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (canvas && ctx && imageData) {
ctx.putImageData(imageData, 0, 0);
setCanUndo(historyIndexRef.current > 0);
setCanRedo(historyIndexRef.current < historyRef.current.length - 1);
scheduleFlush(true);
}
}, [scheduleFlush]);
const redo = useCallback(() => {
if (historyIndexRef.current >= historyRef.current.length - 1) return;
historyIndexRef.current++;
const imageData = historyRef.current[historyIndexRef.current];
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (canvas && ctx && imageData) {
ctx.putImageData(imageData, 0, 0);
setCanUndo(historyIndexRef.current > 0);
setCanRedo(historyIndexRef.current < historyRef.current.length - 1);
scheduleFlush(true);
}
}, [scheduleFlush]);
const resetCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.save();
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "#d8d8db";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.restore();
// Clear history and save initial state
historyRef.current = [];
historyIndexRef.current = -1;
saveHistory();
}, [saveHistory]);
const drawInitialImage = useCallback(async () => {
if (!open || !initialImageUrl || hasLoadedInitialImageRef.current) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const image = new Image();
image.crossOrigin = "anonymous";
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error("加载初始图片失败"));
image.src = initialImageUrl;
});
const scale = Math.min(canvas.width / image.width, canvas.height / image.height);
const drawWidth = image.width * scale;
const drawHeight = image.height * scale;
const offsetX = (canvas.width - drawWidth) / 2;
const offsetY = (canvas.height - drawHeight) / 2;
ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
hasLoadedInitialImageRef.current = true;
dirtyRef.current = true;
saveHistory(); // Save initial image state
}, [initialImageUrl, open, saveHistory]);
const initializeCanvas = useCallback(() => { const initializeCanvas = useCallback(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
const wrap = canvasWrapRef.current; const wrap = canvasWrapRef.current;
@ -376,16 +455,38 @@ export function RealtimeCanvasDialog({
[getCanvasPoint] [getCanvasPoint]
); );
const handlePointerLeave = useCallback(() => {
if (cursorRef.current) {
cursorRef.current.style.opacity = '0';
}
pointerDownRef.current = false;
lastPointRef.current = null;
scheduleFlush(true);
}, [scheduleFlush]);
const handlePointerEnter = useCallback(() => {
if (cursorRef.current) {
cursorRef.current.style.opacity = '1';
}
}, []);
const handlePointerMove = useCallback( const handlePointerMove = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => { (event: ReactPointerEvent<HTMLCanvasElement>) => {
if (!pointerDownRef.current) return; // Update cursor
const point = getCanvasPoint(event); 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`;
}
if (!pointerDownRef.current) return;
if (!point || !lastPointRef.current) return; if (!point || !lastPointRef.current) return;
drawStroke(lastPointRef.current, point); drawStroke(lastPointRef.current, point);
lastPointRef.current = point; lastPointRef.current = point;
scheduleFlush(); scheduleFlush();
}, },
[drawStroke, getCanvasPoint, scheduleFlush] [drawStroke, getCanvasPoint, scheduleFlush, brushSize]
); );
const handlePointerUp = useCallback( const handlePointerUp = useCallback(
@ -397,11 +498,37 @@ export function RealtimeCanvasDialog({
} catch { } catch {
// ignore // ignore
} }
saveHistory(); // Save state after stroke
scheduleFlush(true); scheduleFlush(true);
}, },
[scheduleFlush] [scheduleFlush, saveHistory]
); );
// Shortcuts
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Undo/Redo
if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
e.preventDefault();
if (e.shiftKey) {
redo();
} else {
undo();
}
}
// Brush Size
if (e.key === '[') {
setBrushSize(prev => Math.max(2, prev - 2));
}
if (e.key === ']') {
setBrushSize(prev => Math.min(50, prev + 2));
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open, undo, redo]);
const uploadPreviewToCanvas = useCallback(async () => { const uploadPreviewToCanvas = useCallback(async () => {
if (!previewUrl) { if (!previewUrl) {
toast.error("请先完成一帧实时预览"); toast.error("请先完成一帧实时预览");
@ -612,167 +739,215 @@ export function RealtimeCanvasDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="h-[90vh] w-[96vw] max-w-[1540px] overflow-hidden rounded-[24px] border border-zinc-200/80 bg-[#f2f3f5] p-0 shadow-2xl"> <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="border-b border-zinc-200/80 bg-white/70 px-6 py-4 backdrop-blur"> {/* Top Header - Minimal */}
<div className="flex items-start justify-between gap-4"> <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> <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">
<DialogTitle className="text-[24px] font-semibold tracking-tight text-zinc-900"> <span className="font-semibold text-sm">Realtime Canvas</span>
实时画布 <div className="h-4 w-[1px] bg-border" />
</DialogTitle> <span className={cn("flex items-center gap-1.5 text-xs font-medium", statusBadge.className, "bg-transparent p-0")}>
<DialogDescription className="mt-1 text-[13px] text-zinc-500"> <div className={cn("h-2 w-2 rounded-full", status === "live" ? "bg-emerald-500 animate-pulse" : status === "error" ? "bg-rose-500" : "bg-zinc-400")} />
左侧绘制草图,右侧实时渲染,整体风格与无限画布保持一致 {statusBadge.label}
</DialogDescription> </span>
<div className="mt-2 text-[12px] text-zinc-500">{statusMessage}</div> </div>
</div>
<div className="flex items-center gap-2"> <div className="pointer-events-auto flex items-center gap-2">
<span <Button
className={cn( variant="ghost"
"inline-flex h-9 items-center gap-1.5 rounded-full px-3 text-[12px] font-medium", size="icon"
statusBadge.className className="h-8 w-8 rounded-full bg-background/50 hover:bg-background/80 backdrop-blur-md text-foreground/80 hover:text-foreground transition-colors"
)} onClick={() => onOpenChange(false)}
> >
{status === "error" ? <WifiOff className="h-3.5 w-3.5" /> : <Wifi className="h-3.5 w-3.5" />} <X className="h-4 w-4" />
{statusBadge.label} </Button>
</div>
</div>
{/* Main Canvas Area - Split View */}
<div className="relative flex h-full w-full bg-[#f4f4f5] dark:bg-[#09090b]">
{/* Left: Input Canvas */}
<div className="relative flex-1 flex flex-col items-center justify-center overflow-hidden border-r border-border/10">
<div ref={canvasWrapRef} className="relative h-full w-full flex items-center justify-center bg-white dark:bg-[#18181b]">
<div className="absolute inset-0 pattern-grid-lg opacity-[0.03] pointer-events-none" />
<div className="relative">
<canvas
ref={canvasRef}
className="touch-none shadow-sm block cursor-none"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
/>
{/* Custom Brush Cursor */}
<div
ref={cursorRef}
className="pointer-events-none absolute top-0 left-0 rounded-full border border-zinc-500/50 bg-zinc-500/10 backdrop-invert transition-opacity duration-75 z-10"
style={{
width: brushSize,
height: brushSize,
opacity: 0,
willChange: 'transform, width, height'
}}
/>
</div>
</div>
<div className="absolute top-4 left-4 pointer-events-none z-10">
<span className="text-xs font-medium text-zinc-400 bg-black/5 px-2 py-1 rounded-md backdrop-blur-sm">INPUT</span>
</div>
</div>
{/* Right: Output Preview */}
<div className="relative flex-1 flex flex-col items-center justify-center overflow-hidden bg-zinc-50 dark:bg-[#0c0c0e]">
<div className="relative h-full w-full flex items-center justify-center">
{previewUrl ? (
<img src={previewUrl} alt="实时预览" className="max-h-full max-w-full object-contain shadow-sm" />
) : (
<div className="flex flex-col items-center justify-center text-zinc-400 gap-3">
{status === "connecting" ? (
<>
<Loader2 className="h-8 w-8 animate-spin opacity-50" />
<span className="text-sm">Connecting...</span>
</>
) : (
<>
<Sparkles className="h-8 w-8 opacity-20" />
<span className="text-sm">Start drawing to generate</span>
</>
)}
</div>
)}
</div>
<div className="absolute top-4 right-4 pointer-events-none">
<span className="text-xs font-medium text-zinc-400 bg-black/5 px-2 py-1 rounded-md backdrop-blur-sm">OUTPUT</span>
</div>
</div>
</div>
{/* Floating Toolbar - Left (Tools) */}
<div className="absolute left-6 top-1/2 -translate-y-1/2 flex flex-col gap-4 z-20 pointer-events-none">
<div className="pointer-events-auto flex flex-col gap-2 p-2 bg-background/90 backdrop-blur-xl border border-border/50 shadow-xl rounded-2xl">
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-xl hover:bg-zinc-100 dark:hover:bg-zinc-800"
onClick={undo}
disabled={!canUndo}
title="Undo (Cmd+Z)"
>
<Undo2 className="h-5 w-5 opacity-70" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-xl hover:bg-zinc-100 dark:hover:bg-zinc-800"
onClick={redo}
disabled={!canRedo}
title="Redo (Cmd+Shift+Z)"
>
<Redo2 className="h-5 w-5 opacity-70" />
</Button>
<div className="h-[1px] bg-border/50 mx-2 my-1" />
<Button
size="icon"
variant={brushMode === "brush" ? "secondary" : "ghost"}
className={cn("h-10 w-10 rounded-xl transition-all", brushMode === "brush" && "bg-primary text-primary-foreground shadow-sm")}
onClick={() => setBrushMode("brush")}
title="Brush"
>
<Paintbrush className="h-5 w-5" />
</Button>
<Button
size="icon"
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")}
>
<Eraser 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">
<span className="text-[10px] font-medium text-muted-foreground">{brushSize}px</span>
<Slider
orientation="vertical"
value={[brushSize]}
min={2}
max={50}
step={1}
onValueChange={([val]) => setBrushSize(val)}
className="h-24 w-4"
/>
</div>
<div className="h-[1px] bg-border/50 mx-2 my-1" />
<Button
size="icon"
variant="ghost"
className="h-10 w-10 rounded-xl hover:bg-destructive/10 hover:text-destructive"
onClick={clearSketch}
>
<Trash2 className="h-5 w-5" />
</Button>
</div>
</div>
{/* Floating Toolbar - Bottom (Prompt) */}
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 w-full max-w-2xl px-4 z-20 pointer-events-none">
<div className="pointer-events-auto flex flex-col gap-2 p-1.5 bg-background/80 backdrop-blur-xl border border-border/50 shadow-2xl rounded-[24px]">
<div className="flex items-center gap-2 pl-2 pr-1.5 py-1">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary">
<Sparkles className="h-4 w-4" />
</div>
<Input
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onBlur={() => scheduleFlush(true)}
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"
/>
<Button
size="sm"
className="h-9 rounded-xl px-4 font-medium"
onClick={() => scheduleFlush(true)}
disabled={status === 'connecting'}
>
{status === 'connecting' ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Generate'}
</Button>
</div>
</div>
<div className="text-center mt-2">
<span className="text-[10px] text-white/50 bg-black/40 px-3 py-1 rounded-full backdrop-blur-sm">
{statusMessage} • Latency: {adaptiveThrottleMs}ms
</span> </span>
</div>
</div>
{/* Floating Toolbar - Right (Actions) */}
<div className="absolute right-6 top-6 flex flex-col gap-3 z-20 pointer-events-none">
<div className="pointer-events-auto flex flex-col gap-2">
<Button <Button
type="button" className="h-10 px-4 rounded-full shadow-lg bg-primary text-primary-foreground hover:bg-primary/90"
variant="outline" onClick={uploadPreviewToCanvas}
size="sm" disabled={!previewUrl || isApplying}
onClick={forceReconnect}
className="h-9 rounded-xl border-zinc-300 bg-white px-3"
> >
<RefreshCw className="mr-1.5 h-3.5 w-3.5" /> {isApplying ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
重连 Send to Canvas
</Button> </Button>
</div>
</div>
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col px-5 pb-5 pt-4">
<div className="grid min-h-0 flex-1 grid-cols-2 gap-4">
<div className="flex min-h-0 flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white/95 shadow-sm">
<div className="flex h-12 items-center justify-between border-b border-zinc-200 px-4">
<span className="text-[15px] font-medium text-zinc-800">草图画布</span>
<div className="flex items-center gap-1.5">
<Button
type="button"
size="icon"
variant={brushMode === "brush" ? "default" : "outline"}
className="h-8 w-8 rounded-lg"
onClick={() => setBrushMode("brush")}
>
<Paintbrush className="h-4 w-4" />
</Button>
<Button
type="button"
size="icon"
variant={brushMode === "eraser" ? "default" : "outline"}
className="h-8 w-8 rounded-lg"
onClick={() => setBrushMode("eraser")}
>
<Eraser className="h-4 w-4" />
</Button>
<Button
type="button"
size="icon"
variant="outline"
className="h-8 w-8 rounded-lg"
onClick={clearSketch}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div ref={canvasWrapRef} className="min-h-0 flex-1 bg-[#d0d1d4] p-3">
<canvas
ref={canvasRef}
className="h-full w-full touch-none rounded-xl border border-zinc-300/80 bg-[#d0d1d4]"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
/>
</div>
<div className="flex h-12 items-center gap-3 border-t border-zinc-200 px-4">
<Label htmlFor="rt-brush-size" className="text-[12px] font-medium text-zinc-500">
画笔粗细
</Label>
<input
id="rt-brush-size"
type="range"
min={2}
max={30}
step={1}
value={brushSize}
onChange={(event) => setBrushSize(Number(event.target.value))}
className="h-2 w-40 accent-zinc-800"
/>
<span className="text-[12px] text-zinc-500">{brushSize}px</span>
</div>
</div>
<div className="flex min-h-0 flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white/95 shadow-sm">
<div className="flex h-12 items-center justify-between border-b border-zinc-200 px-4">
<span className="text-[15px] font-medium text-zinc-800">实时预览</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 rounded-lg border-zinc-300 bg-white px-3 text-[12px]"
onClick={() => scheduleFlush(true)}
>
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
刷新
</Button>
</div>
<div className="relative min-h-0 flex-1 overflow-hidden bg-[#d0d1d4] p-3">
<div className="h-full w-full overflow-hidden rounded-xl border border-zinc-300/80 bg-[#d0d1d4]">
{previewUrl ? (
<img src={previewUrl} alt="实时预览" className="h-full w-full object-contain" />
) : (
<div className="flex h-full items-center justify-center text-[13px] text-zinc-500">
{status === "connecting" ? (
<span className="inline-flex items-center rounded-full bg-white/70 px-3 py-1.5">
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
正在等待首帧...
</span>
) : (
<span>开始绘制后将显示实时预览</span>
)}
</div>
)}
</div>
</div>
</div>
</div>
<div className="mt-4 rounded-2xl border border-zinc-200 bg-white/95 p-3 shadow-sm">
<div className="flex items-center gap-2">
<Input
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
onBlur={() => scheduleFlush(true)}
placeholder="输入提示词,例如:将草图渲染为高端工业设计风格..."
className="h-11 flex-1 rounded-xl border-zinc-300 bg-white text-[15px]"
/>
<Button <Button
type="button" variant="secondary"
className="h-11 rounded-xl px-5" className="h-10 px-4 rounded-full shadow-md bg-background/80 backdrop-blur-md hover:bg-background"
disabled={!previewUrl || isApplying} onClick={forceReconnect}
onClick={uploadPreviewToCanvas}
> >
{isApplying ? ( <RefreshCw className="mr-2 h-4 w-4" />
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> Reconnect
) : (
<Send className="mr-1.5 h-4 w-4" />
)}
发送到画布
</Button> </Button>
</div> </div>
<div className="mt-2 text-[12px] text-zinc-500">
绘制会自动触发实时渲染,当前节流间隔 {adaptiveThrottleMs}ms。
</div>
</div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -8,17 +8,29 @@ import { cn } from "@/lib/utils"
const Slider = React.forwardRef< const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>, React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => ( >(({ className, orientation = "horizontal", ...props }, ref) => (
<SliderPrimitive.Root <SliderPrimitive.Root
ref={ref} ref={ref}
orientation={orientation}
className={cn( className={cn(
"relative flex w-full touch-none select-none items-center", "relative flex touch-none select-none items-center",
orientation === "horizontal" ? "w-full" : "h-full flex-col",
className className
)} )}
{...props} {...props}
> >
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary"> <SliderPrimitive.Track
<SliderPrimitive.Range className="absolute h-full bg-primary" /> className={cn(
"relative grow overflow-hidden rounded-full bg-secondary",
orientation === "horizontal" ? "h-2 w-full" : "h-full w-2"
)}
>
<SliderPrimitive.Range
className={cn(
"absolute bg-primary",
orientation === "horizontal" ? "h-full" : "w-full"
)}
/>
</SliderPrimitive.Track> </SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" /> <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root> </SliderPrimitive.Root>