diff --git a/app/api/canvas/detail-plan/route.ts b/app/api/canvas/detail-plan/route.ts index 8a9a87e40..e99776ddb 100644 --- a/app/api/canvas/detail-plan/route.ts +++ b/app/api/canvas/detail-plan/route.ts @@ -17,6 +17,7 @@ type DetailPlanRequest = { referenceImages?: string[]; referenceImageNotes?: string[]; modelImageUrl?: string; + bundleImageUrls?: string[]; brief?: { productName?: string; targetAudience?: string; @@ -66,6 +67,8 @@ type DetailPlanScreen = { layoutNote: string; useModel: boolean; modelReason: string; + bundleShowcase: boolean; + bundleReason: string; visualPrompt: string; fabe: { b: string; @@ -146,7 +149,7 @@ function buildDetailSkillFlow(language: "zh" | "en") { function clampCount(value: unknown): number { const num = Number(value); if (!Number.isFinite(num)) return 5; - return Math.min(12, Math.max(5, Math.round(num))); + return Math.min(13, Math.max(5, Math.round(num))); } function sanitizeReferenceImages(images: unknown): string[] { @@ -154,7 +157,7 @@ function sanitizeReferenceImages(images: unknown): string[] { return images .map((item) => String(item || "").trim()) .filter((url) => /^https?:\/\//i.test(url)) - .slice(0, 6); + .slice(0, 12); } function sanitizeReferenceImageNotes(notes: unknown): string[] { @@ -162,7 +165,15 @@ function sanitizeReferenceImageNotes(notes: unknown): string[] { return notes .map((item) => String(item || "").replace(/\s+/g, " ").trim()) .filter(Boolean) - .slice(0, 6); + .slice(0, 12); +} + +function sanitizeBundleImages(images: unknown): string[] { + if (!Array.isArray(images)) return []; + return images + .map((item) => String(item || "").trim()) + .filter((url) => /^https?:\/\//i.test(url)) + .slice(0, 3); } function normalizeOutputLanguage(value: unknown): "zh" | "en" { @@ -289,8 +300,20 @@ function buildFallbackScreens(params: { globalStyle: DetailPlanResponse["globalStyle"]; referenceImageNotes: string[]; hasModelImage: boolean; + hasBundleImages: boolean; + bundleImageCount: number; }): DetailPlanScreen[] { - const { prompt, count, language, brief, globalStyle, referenceImageNotes, hasModelImage } = params; + const { + prompt, + count, + language, + brief, + globalStyle, + referenceImageNotes, + hasModelImage, + hasBundleImages, + bundleImageCount, + } = params; const productLabel = brief.productName || (language === "en" ? "the product" : "该产品"); const keyword = @@ -326,23 +349,40 @@ function buildFallbackScreens(params: { (language === "en" ? `Extended detail frame ${index + 1}` : `延展分镜 ${index + 1}`); + const isBundleScreen = hasBundleImages && index === count - 1; const title = - language === "en" + isBundleScreen + ? language === "en" + ? "Cross-sell Bundle Scene" + : "关联销售搭配场景" + : language === "en" ? `${keyword} Frame ${index + 1}` : `${keyword} 第${index + 1}屏`; const subtitle = - language === "en" + isBundleScreen + ? language === "en" + ? `Show product pairing with ${bundleImageCount} optional companion SKU(s) to increase basket size.` + : `联动${bundleImageCount}张搭配产品参考图,强化关联销售与加购动机。` + : language === "en" ? `For ${brief.targetAudience}, solve "${coreNeed}" in ${mainScenario}.` : `面向${brief.targetAudience},在${mainScenario}场景解决“${coreNeed}”。`; const copy = - language === "en" + 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}将关键痛点转为可感知结果,形成清晰购买决策路径。`; const layoutNote = - language === "en" + isBundleScreen + ? language === "en" + ? "Main product remains primary; companion products are secondary with clear hierarchy and no visual clutter." + : "主产品为视觉主角,搭配产品作为辅助,层级清晰避免喧宾夺主。" + : language === "en" ? `Use fixed layout rhythm. Keep title/subtitle/body hierarchy identical to all other frames.` : `使用固定版式节奏,标题/副标题/正文层级与其余页面完全一致。`; - const useModel = hasModelImage && (index === 1 || index === 3 || index >= 5); + const useModel = !isBundleScreen && hasModelImage && (index === 1 || index === 3 || index >= 5); const modelReason = useModel ? language === "en" ? "This frame needs human scenario demonstration, so use the fixed model image." @@ -350,6 +390,14 @@ function buildFallbackScreens(params: { : language === "en" ? "This frame focuses on product-only communication without human figure." : "本屏聚焦产品信息展示,不使用人物。"; + const bundleShowcase = isBundleScreen; + const bundleReason = bundleShowcase + ? language === "en" + ? "Optional bundle references were provided; this frame must show scenario-based product pairing for cross-sell." + : "用户提供了搭配产品图,本屏必须展示场景化产品搭配以增强关联销售。" + : language === "en" + ? "This frame is not the bundle showcase frame." + : "本屏非搭配展示页。"; const fabe = language === "en" ? { @@ -380,6 +428,9 @@ function buildFallbackScreens(params: { referenceImageNotes.length > 0 ? `Reference image set roles: ${referenceImageNotes.join(" | ")}. Keep product identity and details fully consistent.` : "", + bundleShowcase + ? `This must be a bundle showcase frame: show the hero product with companion products from optional bundle refs in one coherent scenario.` + : "", useModel ? "Integrate the same human model from the uploaded model image." : "No human figure in this frame.", @@ -399,6 +450,9 @@ function buildFallbackScreens(params: { referenceImageNotes.length > 0 ? `参考图角色:${referenceImageNotes.join(";")};必须严格保持产品一致性与细节一致性。` : "", + bundleShowcase + ? "本屏必须是搭配展示页:同屏展示主产品与搭配产品,体现组合使用场景与加购价值。" + : "", useModel ? "本屏融入固定模特图中的同一人物。" : "本屏不要出现人物。", "商业摄影质感,留出清晰文案排版区,不要水印。", `任务上下文:${prompt}。`, @@ -414,6 +468,8 @@ function buildFallbackScreens(params: { layoutNote, useModel, modelReason, + bundleShowcase, + bundleReason, visualPrompt, fabe, }; @@ -427,6 +483,8 @@ function buildFallbackPlan(params: { brief: DetailBrief; referenceImageNotes: string[]; hasModelImage: boolean; + hasBundleImages: boolean; + bundleImageCount: number; }): DetailPlanResponse { const { count, language } = params; const globalStyle = buildDefaultGlobalStyle(language); @@ -438,6 +496,7 @@ function buildFallbackPlan(params: { const qualityChecks = evaluatePlanQuality({ screens, hasModelImage: params.hasModelImage, + hasBundleImages: params.hasBundleImages, }); return { summary: @@ -484,6 +543,11 @@ function sanitizeScreen( useModel: typeof source.useModel === "boolean" ? source.useModel : fallback.useModel, modelReason: safeText(source.modelReason, fallback.modelReason), + bundleShowcase: + typeof source.bundleShowcase === "boolean" + ? source.bundleShowcase + : fallback.bundleShowcase, + bundleReason: safeText(source.bundleReason, fallback.bundleReason), visualPrompt: visualPrompt.includes("790x1300") ? visualPrompt : `${visualPrompt} 画布固定 790x1300 像素。`, @@ -537,8 +601,9 @@ function sanitizeGlobalStyle( function evaluatePlanQuality(params: { screens: DetailPlanScreen[]; hasModelImage: boolean; + hasBundleImages: boolean; }): { passed: boolean; issues: string[] } { - const { screens, hasModelImage } = params; + const { screens, hasModelImage, hasBundleImages } = params; const issues: string[] = []; if (screens.length === 0) { issues.push("未生成任何分镜。"); @@ -591,6 +656,13 @@ function evaluatePlanQuality(params: { } } + if (hasBundleImages) { + const bundleScreenCount = screens.filter((screen) => screen.bundleShowcase).length; + if (bundleScreenCount === 0) { + issues.push("已提供搭配产品图但没有搭配展示分镜。"); + } + } + return { passed: issues.length === 0, issues, @@ -610,11 +682,14 @@ export async function POST(request: Request) { return NextResponse.json({ success: false, error: "请输入详情需求" }, { status: 400 }); } - const count = clampCount(body.count); + const baseCount = clampCount(body.count); const referenceImages = sanitizeReferenceImages(body.referenceImages); const referenceImageNotes = sanitizeReferenceImageNotes(body.referenceImageNotes); const modelImageUrl = String(body.modelImageUrl || "").trim(); + const bundleImageUrls = sanitizeBundleImages(body.bundleImageUrls); const hasModelImage = /^https?:\/\//i.test(modelImageUrl); + const hasBundleImages = bundleImageUrls.length > 0; + const count = hasBundleImages ? Math.min(13, baseCount + 1) : baseCount; const language = normalizeOutputLanguage(body.outputLanguage); const brief = normalizeDetailBrief(body.brief); if (!brief.targetAudience || !brief.usageScenario || !brief.coreNeed) { @@ -633,6 +708,8 @@ export async function POST(request: Request) { brief, referenceImageNotes, hasModelImage, + hasBundleImages, + bundleImageCount: bundleImageUrls.length, }); const allModels = await getEnabledAiModelsWithProvider(false); @@ -680,6 +757,9 @@ export async function POST(request: Request) { ? `参考图说明(按上传顺序):${referenceImageNotes.join(" | ")}` : "", hasModelImage ? "已提供固定模特图,人物场景必须复用该模特。" : "未提供模特图。", + hasBundleImages + ? `已提供 ${bundleImageUrls.length} 张搭配产品图,必须增加1屏场景化搭配展示用于关联销售。` + : "未提供搭配产品图。", referenceImages.length > 0 ? `提供了 ${referenceImages.length} 张参考产品图。` : "本轮未提供参考图。", ] .filter(Boolean) @@ -719,8 +799,11 @@ export async function POST(request: Request) { (hasModelImage ? `10) 已提供模特图:涉及人物场景时必须复用同一模特(人脸、发型、体型、肤色与服装风格一致),不得替换其他人物。\n` : `10) 未提供模特图:所有分镜均设置 useModel=false。\n`) + + (hasBundleImages + ? `11) 已提供搭配产品图:必须新增且仅新增1屏“场景化产品搭配展示”,该屏设定 bundleShowcase=true,并在 bundleReason 说明搭配策略;其余屏设为 bundleShowcase=false。\n` + : `11) 未提供搭配产品图:所有分镜均设置 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":"是否需要人物及原因","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, maxTokens: 1800, } as any); @@ -736,11 +819,21 @@ export async function POST(request: Request) { modelReason: "未提供模特图,当前分镜不使用人物。", } ); + const normalizedScreens = rawScreens.map((item) => + hasBundleImages + ? item + : { + ...item, + bundleShowcase: false, + bundleReason: "未提供搭配产品图,当前分镜非搭配展示页。", + } + ); const qualityChecks = evaluatePlanQuality({ - screens: rawScreens, + screens: normalizedScreens, hasModelImage, + hasBundleImages, }); - const screens = qualityChecks.passed ? rawScreens : fallback.screens; + const screens = qualityChecks.passed ? normalizedScreens : fallback.screens; const prompts = screens.map((item) => item.visualPrompt); const summaryRaw = String(parsed?.summary || "").trim(); const summary = qualityChecks.passed diff --git a/app/api/canvas/generate/image/route.ts b/app/api/canvas/generate/image/route.ts index 73615e5b0..07c23b025 100644 --- a/app/api/canvas/generate/image/route.ts +++ b/app/api/canvas/generate/image/route.ts @@ -118,7 +118,7 @@ export async function POST(request: Request) { .map((item) => String(item || "").trim()) .filter((url) => /^https?:\/\//i.test(url)) ) - ).slice(0, 6); + ).slice(0, 12); // 构建消息 const messages = [{ diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index cd51119d2..8befb66b5 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -305,7 +305,7 @@ type DetailBriefForm = { type DetailSlotConfig = { key: string; label: string; - role: "product" | "model"; + role: "product" | "model" | "bundle"; }; type DetailSectionKey = "required" | "strategy" | "assets"; @@ -342,6 +342,8 @@ type DetailPlanResponse = { layoutNote?: string; useModel?: boolean; modelReason?: string; + bundleShowcase?: boolean; + bundleReason?: string; visualPrompt?: string; fabe?: { b?: string; @@ -375,7 +377,17 @@ const DETAIL_SLOT_CONFIGS: DetailSlotConfig[] = [ { key: "product-4", label: "产品图4", role: "product" }, { key: "product-5", label: "产品图5", role: "product" }, { key: "model-1", label: "模特图", role: "model" }, + { key: "bundle-1", label: "搭配图1", role: "bundle" }, + { key: "bundle-2", label: "搭配图2", role: "bundle" }, + { key: "bundle-3", label: "搭配图3", role: "bundle" }, ]; +const DETAIL_PRODUCT_SLOT_INDEXES = DETAIL_SLOT_CONFIGS.map((slot, index) => + slot.role === "product" ? index : -1 +).filter((index) => index >= 0); +const DETAIL_MODEL_SLOT_INDEX = DETAIL_SLOT_CONFIGS.findIndex((slot) => slot.role === "model"); +const DETAIL_BUNDLE_SLOT_INDEXES = DETAIL_SLOT_CONFIGS.map((slot, index) => + slot.role === "bundle" ? index : -1 +).filter((index) => index >= 0); const DETAIL_FIELD_EXAMPLES: Record = { productName: "示例:Buyi-AI 无线手持吸尘器", targetAudience: "示例:25-40岁养宠家庭、宝妈", @@ -461,6 +473,8 @@ function buildDetailResultMarkdown(params: { layoutNote?: string; useModel?: boolean; modelReason?: string; + bundleShowcase?: boolean; + bundleReason?: string; fabe?: { b?: string; f?: string; @@ -526,6 +540,12 @@ function buildDetailResultMarkdown(params: { if (screen?.modelReason) { lines.push(`人物策略:${screen.modelReason}`); } + if (screen?.bundleShowcase !== undefined) { + lines.push(screen.bundleShowcase ? "搭配展示:是" : "搭配展示:否"); + } + if (screen?.bundleReason) { + lines.push(`搭配策略:${screen.bundleReason}`); + } if (screen?.fabe) { const fabeParts = [ screen.fabe.b ? `B ${screen.fabe.b}` : "", @@ -1467,9 +1487,11 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF if (!normalizedDetailBrief.targetAudience) issues.push("缺少主购买人群"); if (!normalizedDetailBrief.usageScenario) issues.push("缺少需求场景"); if (!normalizedDetailBrief.coreNeed) issues.push("缺少核心需求"); - const productCount = detailSlots.slice(0, 5).filter((item) => Boolean(item?.url)).length; + const productCount = DETAIL_PRODUCT_SLOT_INDEXES.filter((index) => Boolean(detailSlots[index]?.url)).length; if (productCount < 5) issues.push(`产品图不足(${productCount}/5)`); - if (!detailSlots[5]?.url) issues.push("缺少模特图(1/1)"); + if (DETAIL_MODEL_SLOT_INDEX < 0 || !detailSlots[DETAIL_MODEL_SLOT_INDEX]?.url) { + issues.push("缺少模特图(1/1)"); + } return issues; }, [detailSlots, normalizedDetailBrief]); @@ -2195,18 +2217,22 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF toast.error("请先补充 AI详情生成必填项:主购买人群、需求场景、核心需求"); return; } - const productAttachments = detailSlots - .slice(0, 5) + const productAttachments = DETAIL_PRODUCT_SLOT_INDEXES + .map((index) => detailSlots[index]) .filter((item): item is PickedObjectAttachment => Boolean(item?.url)); if (productAttachments.length < 5) { toast.error("请先上传 5 张产品图后再执行 AI详情生成"); return; } - const modelAttachment = detailSlots[5]; + const modelAttachment = + DETAIL_MODEL_SLOT_INDEX >= 0 ? detailSlots[DETAIL_MODEL_SLOT_INDEX] : null; if (!modelAttachment?.url) { toast.error("请上传 1 张人物模特图后再执行 AI详情生成"); return; } + const bundleAttachments = DETAIL_BUNDLE_SLOT_INDEXES + .map((index) => detailSlots[index]) + .filter((item): item is PickedObjectAttachment => Boolean(item?.url)); const userText = input .replace(/\u200b/g, " ") @@ -2236,7 +2262,11 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const current = detailSlots[index]; return `${index + 1}. ${config.label}${current?.name ? `(${current.name})` : ""}`; }); - const modelImageHint = `模特图固定策略:涉及人物场景时必须使用第6张模特图同一人物,不得替换人物。`; + const modelImageHint = `模特图固定策略:涉及人物场景时必须使用第${DETAIL_MODEL_SLOT_INDEX + 1}张模特图同一人物,不得替换人物。`; + const bundleImageHint = + bundleAttachments.length > 0 + ? `已上传${bundleAttachments.length}张搭配产品图:必须新增1屏场景化产品搭配展示,突出关联销售。` + : "未上传搭配产品图:不强制搭配展示屏。"; const detailBriefText = [ "【AI详情生成输入】", brief.productName ? `产品:${brief.productName}` : "", @@ -2256,6 +2286,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF brief.bannedWords ? `禁用表达:${brief.bannedWords}` : "", `参考图说明:${referenceImageNotes.join(";")}`, modelImageHint, + bundleImageHint, `输出规格:每屏 ${DETAIL_TARGET_WIDTH}x${DETAIL_TARGET_HEIGHT} 像素`, ] .filter(Boolean) @@ -2320,9 +2351,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF body: JSON.stringify({ prompt: detailPrompt, count: detailCount, - referenceImages: detailReferenceAttachments.map((item) => item.url).slice(0, 6), + referenceImages: detailReferenceAttachments.map((item) => item.url).slice(0, 12), referenceImageNotes, modelImageUrl: modelAttachment.url, + bundleImageUrls: bundleAttachments.map((item) => item.url), outputLanguage: /[a-zA-Z]/.test(detailPrompt) ? "en" : "zh", brief, }), @@ -2366,6 +2398,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF for (let index = 0; index < prompts.length; index += 1) { const screen = screenSpecs[index]; const useModelForScene = Boolean(screen?.useModel); + const bundleShowcase = Boolean((screen as any)?.bundleShowcase); + const bundleReason = String((screen as any)?.bundleReason || "").trim(); const promptText = String(screen?.visualPrompt || "").trim() || prompts[index]!; setDetailProgress({ @@ -2418,6 +2452,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF ? `人物规则:本屏涉及人物场景,固定使用提供的模特图同一人物(面部、发型、体型、服装风格保持一致)。` : "人物规则:本屏不要出现人物或人体部位,只展示产品与场景元素。", screen?.modelReason ? `人物融入依据:${screen.modelReason}` : "", + bundleShowcase + ? "搭配展示规则:本屏必须同场景展示主产品与搭配产品,主次层级清晰,突出组合购买价值与加购理由。" + : "", + bundleReason ? `搭配依据:${bundleReason}` : "", screen?.title ? `本屏标题:${screen.title}` : "", screen?.subtitle ? `本屏副标题:${screen.subtitle}` : "", screen?.copy ? `本屏文案说明:${screen.copy}` : "", @@ -2435,6 +2473,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF index > 0 ? generatedImages[0]?.originalUrl || generatedImages[0]?.thumbnailUrl : null; const generationReferenceImages = [ ...productAttachments.map((item) => item.url), + ...(bundleShowcase ? bundleAttachments.map((item) => item.url) : []), ...(useModelForScene ? [modelAttachment.url] : []), ...(styleAnchorUrl ? [styleAnchorUrl] : []), ].filter(Boolean); @@ -2493,6 +2532,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF detailLayoutNote: screen?.layoutNote || "", detailUseModel: useModelForScene, detailModelReason: screen?.modelReason || "", + detailBundleShowcase: bundleShowcase, + detailBundleReason: bundleReason, detailGlobalStyle: planData.globalStyle || null, }, }, @@ -2974,8 +3015,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const isLoading = status === 'streaming' || status === 'submitted'; const isSubmitting = isLoading || isDetailGenerating; const hasInputOrAttachments = Boolean(input.trim() || attachments.length > 0); - const detailProductCount = detailSlots.slice(0, 5).filter((item) => Boolean(item?.url)).length; - const detailModelReady = Boolean(detailSlots[5]?.url); + const detailProductCount = DETAIL_PRODUCT_SLOT_INDEXES.filter((index) => Boolean(detailSlots[index]?.url)).length; + const detailModelReady = DETAIL_MODEL_SLOT_INDEX >= 0 ? Boolean(detailSlots[DETAIL_MODEL_SLOT_INDEX]?.url) : false; + const detailBundleCount = DETAIL_BUNDLE_SLOT_INDEXES.filter((index) => Boolean(detailSlots[index]?.url)).length; const detailProgressPercent = detailProgress.total > 0 ? Math.min(100, Math.round((detailProgress.current / detailProgress.total) * 100)) @@ -3790,7 +3832,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF >

素材上传

-

固定 5 张产品图 + 1 张模特图

+

固定 5 张产品图 + 1 张模特图 + 可选 3 张搭配图

- Agent 会自动判断是否使用模特图,人物相关分镜将固定使用同一模特。 + Agent 会自动判断是否使用模特图;若上传搭配图,将自动新增一屏“场景化产品搭配展示”用于关联销售。
{DETAIL_SLOT_CONFIGS.map((config, index) => { @@ -3875,7 +3917,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF })}
- 当前素材:产品图 {detailProductCount}/5,模特图 {detailModelReady ? "1/1" : "0/1"} + 当前素材:产品图 {detailProductCount}/5,模特图 {detailModelReady ? "1/1" : "0/1"},搭配图 {detailBundleCount}/3(可选)
) : null}