From e15806bd9177e588dc9cb3923a2e9d3ed619f0b4 Mon Sep 17 00:00:00 2001 From: liangxu Date: Thu, 30 Apr 2026 01:30:29 +0800 Subject: [PATCH] feat(template-wizard): require library brand before favoriting competitors Drop the implicit brand-creation path in handleFavoriteCompetitor and just refuse to save a competitor until the user has explicitly chosen a library brand; clear saved state on competitor drafts when the brand is unset. Also enforce the configured custom-outline label length cap and auto-focus newly added outline nodes for faster keyboard editing. New i18n strings cover the brand-required and outline-length messages plus the updated competitor hint. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/admin-web/src/i18n/messages/en-US.ts | 4 +- apps/admin-web/src/i18n/messages/zh-CN.ts | 7 +- .../src/views/TemplateWizardView.vue | 117 ++++++++++++------ 3 files changed, 87 insertions(+), 41 deletions(-) diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index 97533c1..30fb6d5 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -779,7 +779,7 @@ const enUS = { brandFlow: "You can pick a brand from the library or type one manually. AI analysis will expand keywords, competitors, and title options.", brandSummaryPlaceholder: "After analysis, AI will summarize the brand angle, positioning, and content opportunities here.", keywords: "Type keywords directly or reuse brand-library keywords. AI results will be merged into this list.", - competitors: "Competitors stay editable. You can add rows manually and save useful ones back into the competitor library.", + competitors: "Competitors stay editable. You can add rows manually; saving to the competitor library is only available after choosing a library brand.", competitorEmptyEditable: "No competitors yet. Add one manually or let AI fill the list first.", templateFields: "Keep any extra template-specific fields here to constrain the writing direction.", titleSelection: "AI will return 5 better title options before you move forward. Choose one or rewrite it.", @@ -866,6 +866,8 @@ const enUS = { missingTitle: "Please choose or enter an article title.", missingOutline: "Please select at least one outline section.", missingGeneratedOutline: "Please generate and confirm the article outline first.", + customOutlineTooLong: "Custom sections cannot exceed {count} characters.", + selectBrandBeforeFavorite: "Choose a brand from the library before saving competitors.", missingCompetitorName: "Enter a competitor name before saving it to the library.", savedCompetitor: "Competitor saved to the brand library.", assistReady: "AI analysis is ready. You can keep refining the draft.", diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index 75c19a6..c803ceb 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -91,7 +91,6 @@ const zhCN = { }, auth: { welcomeBack: "欢迎回来", - tenantAdminLogin: "租户后台登录", loginAndEnter: "登录并进入工作台", email: "邮箱", loginIdentifier: "手机号 / 邮箱", @@ -596,7 +595,7 @@ const zhCN = { publishing: "发布中", success: "发布成功", failed: "发布失败", - partial_success: "部分发布", + partial_success: "部分成功", published: "发布成功", publish_success: "发布成功", publish_failed: "发布失败", @@ -781,7 +780,7 @@ const zhCN = { brandFlow: "品牌可直接输入,也可以从品牌库带入。点击分析后,AI 会补充关键词、竞品和标题建议。", brandSummaryPlaceholder: "AI 分析后会在这里补充品牌简介、差异点和适合切入的内容角度。", keywords: "支持直接输入,也支持复用品牌词库里的关键词。AI 分析结果会自动补充到这里。", - competitors: "竞品支持手动编辑、追加和收藏到品牌词库。AI 分析会尽量补齐候选竞品。", + competitors: "竞品支持手动编辑、追加;只有从品牌库选择品牌后,才可以收藏到品牌词库。AI 分析会尽量补齐候选竞品。", competitorEmptyEditable: "还没有竞品,先手动添加一条或点击分析让 AI 自动补齐。", templateFields: "这里保留当前模板自己的补充字段,用来约束 AI 生成角度。", titleSelection: "下一步前会由 AI 给出 5 个更合适的标题候选,你可以在这里挑选或改写。", @@ -874,6 +873,8 @@ const zhCN = { missingReviewIntroHook: "请先选择一个评测引言钩子", missingOutline: "请至少选择一个文章结构段落", missingGeneratedOutline: "请先生成并确认文章大纲", + customOutlineTooLong: "自定义结构不能超过 {count} 个字", + selectBrandBeforeFavorite: "请先从品牌库选择品牌后再收藏竞品", missingCompetitorName: "至少需要填写竞品名称后才能收藏", savedCompetitor: "竞品已收藏到品牌词库", assistReady: "AI 分析已完成,可以继续调整内容。", diff --git a/apps/admin-web/src/views/TemplateWizardView.vue b/apps/admin-web/src/views/TemplateWizardView.vue index 99241eb..752865a 100644 --- a/apps/admin-web/src/views/TemplateWizardView.vue +++ b/apps/admin-web/src/views/TemplateWizardView.vue @@ -15,7 +15,7 @@ import type { TemplateAnalyzeResult, TemplateOutlineNode, } from "@geo/shared-types"; -import { computed, ref, watch } from "vue"; +import { computed, nextTick, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; import { useRoute, useRouter } from "vue-router"; @@ -151,6 +151,7 @@ const router = useRouter(); let competitorKeySeed = 0; let outlineNodeSeed = 0; +const CUSTOM_OUTLINE_MAX_LENGTH = 15; const currentStep = ref(0); const articleLocale = ref("zh-CN"); @@ -284,6 +285,8 @@ const primaryKeyword = computed(() => keywordDrafts.value[0]?.trim() || ""); const showCompetitorsCard = computed( () => !isResearchReportTemplate.value && wizardConfig.value?.basic?.cards?.competitors?.visible !== false, ); +const canSaveCompetitorsToLibrary = computed(() => Boolean(selectedBrandId.value)); +const customOutlineMaxLength = computed(() => wizardConfig.value?.outline?.custom_max_length ?? CUSTOM_OUTLINE_MAX_LENGTH); const reviewIntroHookConfig = computed( () => wizardConfig.value?.structure?.review_intro_hook, ); @@ -546,6 +549,11 @@ watch(selectedBrandId, async (brandId) => { return; } if (!brandId) { + competitorDrafts.value = competitorDrafts.value.map((item) => ({ + ...item, + libraryId: undefined, + saved: false, + })); return; } @@ -881,6 +889,11 @@ function removeCompetitorRow(key: string): void { } async function handleFavoriteCompetitor(item: DraftCompetitor): Promise { + const brandId = selectedBrandId.value; + if (!brandId) { + message.warning(t("templates.wizard.messages.selectBrandBeforeFavorite")); + return; + } if (!item.name.trim()) { message.warning(t("templates.wizard.messages.missingCompetitorName")); return; @@ -889,7 +902,6 @@ async function handleFavoriteCompetitor(item: DraftCompetitor): Promise { competitorSavingKey.value = item.key; try { - const brandId = await ensureBrandForLibrary(); const payload = { name: item.name.trim(), website: item.website.trim() || null, @@ -917,21 +929,6 @@ async function handleFavoriteCompetitor(item: DraftCompetitor): Promise { } } -async function ensureBrandForLibrary(): Promise { - if (selectedBrandId.value) { - return selectedBrandId.value; - } - - const created = await brandsApi.create({ - name: normalizedBrandName.value, - website: officialWebsite.value.trim() || null, - description: brandSummary.value.trim() || null, - }); - selectedBrandId.value = created.id; - await queryClient.invalidateQueries({ queryKey: ["brands"] }); - return created.id; -} - function buildCompetitorPayload(): TemplateAssistCompetitor[] { if (!showCompetitorsCard.value) { return []; @@ -1161,6 +1158,10 @@ function addCustomOutlineSection(): void { if (!label) { return; } + if (Array.from(label).length > customOutlineMaxLength.value) { + message.warning(t("templates.wizard.messages.customOutlineTooLong", { count: customOutlineMaxLength.value })); + return; + } const key = `custom-${Date.now()}`; customOutlineSections.value = [ @@ -1277,53 +1278,77 @@ function serializeOutlinePayloadNode(node: TemplateOutlineNode): JsonValue { } function addOutlineRootNode(): void { + const node = createEmptyOutlineNode(); generatedOutline.value = [ ...generatedOutline.value, - { id: nextOutlineNodeKey(), outline: "", children: [] }, + node, ]; + focusOutlineNode(node.id); } function addOutlineNodeAfter(targetId: string): void { - generatedOutline.value = insertOutlineSibling(generatedOutline.value, targetId); + const node = createEmptyOutlineNode(); + generatedOutline.value = insertOutlineSibling(generatedOutline.value, targetId, node); + focusOutlineNode(node.id); } function addOutlineChildNode(targetId: string): void { - generatedOutline.value = insertOutlineChild(generatedOutline.value, targetId); + const node = createEmptyOutlineNode(); + generatedOutline.value = insertOutlineChild(generatedOutline.value, targetId, node); + focusOutlineNode(node.id); } function removeOutlineNode(targetId: string): void { generatedOutline.value = deleteOutlineNode(generatedOutline.value, targetId); } -function insertOutlineSibling(nodes: OutlineDraftNode[], targetId: string): OutlineDraftNode[] { +function createEmptyOutlineNode(): OutlineDraftNode { + return { id: nextOutlineNodeKey(), outline: "", children: [] }; +} + +async function focusOutlineNode(nodeId: string): Promise { + await nextTick(); + window.requestAnimationFrame(() => { + const target = document.querySelector(`[data-outline-node-id="${nodeId}"]`); + target?.scrollIntoView({ behavior: "smooth", block: "center" }); + target?.focus({ preventScroll: true }); + }); +} + +function insertOutlineSibling( + nodes: OutlineDraftNode[], + targetId: string, + insertedNode: OutlineDraftNode, +): OutlineDraftNode[] { const next: OutlineDraftNode[] = []; for (const node of nodes) { const current: OutlineDraftNode = { ...node, - children: insertOutlineSibling(node.children, targetId), + children: insertOutlineSibling(node.children, targetId, insertedNode), }; next.push(current); if (node.id === targetId) { - next.push({ id: nextOutlineNodeKey(), outline: "", children: [] }); + next.push(insertedNode); } } return next; } -function insertOutlineChild(nodes: OutlineDraftNode[], targetId: string): OutlineDraftNode[] { +function insertOutlineChild( + nodes: OutlineDraftNode[], + targetId: string, + insertedNode: OutlineDraftNode, +): OutlineDraftNode[] { return nodes.map((node) => { if (node.id === targetId) { return { ...node, - children: [ - ...node.children, - { id: nextOutlineNodeKey(), outline: "", children: [] }, - ], + children: [...node.children, insertedNode], }; } return { ...node, - children: insertOutlineChild(node.children, targetId), + children: insertOutlineChild(node.children, targetId, insertedNode), }; }); } @@ -2147,14 +2172,29 @@ function onStructureDragEnd(): void { :placeholder="t('templates.wizard.placeholders.competitorDescription')" />
- - - {{ item.saved ? t("templates.wizard.actions.saved") : t("templates.wizard.actions.favorite") }} - + + + + {{ + canSaveCompetitorsToLibrary && item.saved + ? t("templates.wizard.actions.saved") + : t("templates.wizard.actions.favorite") + }} + + + @@ -2280,7 +2320,8 @@ function onStructureDragEnd(): void {
@@ -2395,6 +2437,7 @@ function onStructureDragEnd(): void {