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) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 01:30:29 +08:00
parent 14a7921445
commit e15806bd91
3 changed files with 87 additions and 41 deletions
+3 -1
View File
@@ -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.", 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.", 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.", 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.", 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.", 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.", 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.", missingTitle: "Please choose or enter an article title.",
missingOutline: "Please select at least one outline section.", missingOutline: "Please select at least one outline section.",
missingGeneratedOutline: "Please generate and confirm the article outline first.", 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.", missingCompetitorName: "Enter a competitor name before saving it to the library.",
savedCompetitor: "Competitor saved to the brand library.", savedCompetitor: "Competitor saved to the brand library.",
assistReady: "AI analysis is ready. You can keep refining the draft.", assistReady: "AI analysis is ready. You can keep refining the draft.",
+4 -3
View File
@@ -91,7 +91,6 @@ const zhCN = {
}, },
auth: { auth: {
welcomeBack: "欢迎回来", welcomeBack: "欢迎回来",
tenantAdminLogin: "租户后台登录",
loginAndEnter: "登录并进入工作台", loginAndEnter: "登录并进入工作台",
email: "邮箱", email: "邮箱",
loginIdentifier: "手机号 / 邮箱", loginIdentifier: "手机号 / 邮箱",
@@ -596,7 +595,7 @@ const zhCN = {
publishing: "发布中", publishing: "发布中",
success: "发布成功", success: "发布成功",
failed: "发布失败", failed: "发布失败",
partial_success: "部分发布", partial_success: "部分成功",
published: "发布成功", published: "发布成功",
publish_success: "发布成功", publish_success: "发布成功",
publish_failed: "发布失败", publish_failed: "发布失败",
@@ -781,7 +780,7 @@ const zhCN = {
brandFlow: "品牌可直接输入,也可以从品牌库带入。点击分析后,AI 会补充关键词、竞品和标题建议。", brandFlow: "品牌可直接输入,也可以从品牌库带入。点击分析后,AI 会补充关键词、竞品和标题建议。",
brandSummaryPlaceholder: "AI 分析后会在这里补充品牌简介、差异点和适合切入的内容角度。", brandSummaryPlaceholder: "AI 分析后会在这里补充品牌简介、差异点和适合切入的内容角度。",
keywords: "支持直接输入,也支持复用品牌词库里的关键词。AI 分析结果会自动补充到这里。", keywords: "支持直接输入,也支持复用品牌词库里的关键词。AI 分析结果会自动补充到这里。",
competitors: "竞品支持手动编辑、追加收藏到品牌词库。AI 分析会尽量补齐候选竞品。", competitors: "竞品支持手动编辑、追加;只有从品牌库选择品牌后,才可以收藏到品牌词库。AI 分析会尽量补齐候选竞品。",
competitorEmptyEditable: "还没有竞品,先手动添加一条或点击分析让 AI 自动补齐。", competitorEmptyEditable: "还没有竞品,先手动添加一条或点击分析让 AI 自动补齐。",
templateFields: "这里保留当前模板自己的补充字段,用来约束 AI 生成角度。", templateFields: "这里保留当前模板自己的补充字段,用来约束 AI 生成角度。",
titleSelection: "下一步前会由 AI 给出 5 个更合适的标题候选,你可以在这里挑选或改写。", titleSelection: "下一步前会由 AI 给出 5 个更合适的标题候选,你可以在这里挑选或改写。",
@@ -874,6 +873,8 @@ const zhCN = {
missingReviewIntroHook: "请先选择一个评测引言钩子", missingReviewIntroHook: "请先选择一个评测引言钩子",
missingOutline: "请至少选择一个文章结构段落", missingOutline: "请至少选择一个文章结构段落",
missingGeneratedOutline: "请先生成并确认文章大纲", missingGeneratedOutline: "请先生成并确认文章大纲",
customOutlineTooLong: "自定义结构不能超过 {count} 个字",
selectBrandBeforeFavorite: "请先从品牌库选择品牌后再收藏竞品",
missingCompetitorName: "至少需要填写竞品名称后才能收藏", missingCompetitorName: "至少需要填写竞品名称后才能收藏",
savedCompetitor: "竞品已收藏到品牌词库", savedCompetitor: "竞品已收藏到品牌词库",
assistReady: "AI 分析已完成,可以继续调整内容。", assistReady: "AI 分析已完成,可以继续调整内容。",
+80 -37
View File
@@ -15,7 +15,7 @@ import type {
TemplateAnalyzeResult, TemplateAnalyzeResult,
TemplateOutlineNode, TemplateOutlineNode,
} from "@geo/shared-types"; } from "@geo/shared-types";
import { computed, ref, watch } from "vue"; import { computed, nextTick, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
@@ -151,6 +151,7 @@ const router = useRouter();
let competitorKeySeed = 0; let competitorKeySeed = 0;
let outlineNodeSeed = 0; let outlineNodeSeed = 0;
const CUSTOM_OUTLINE_MAX_LENGTH = 15;
const currentStep = ref(0); const currentStep = ref(0);
const articleLocale = ref("zh-CN"); const articleLocale = ref("zh-CN");
@@ -284,6 +285,8 @@ const primaryKeyword = computed(() => keywordDrafts.value[0]?.trim() || "");
const showCompetitorsCard = computed( const showCompetitorsCard = computed(
() => !isResearchReportTemplate.value && wizardConfig.value?.basic?.cards?.competitors?.visible !== false, () => !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<TemplateReviewIntroHookConfig | undefined>( const reviewIntroHookConfig = computed<TemplateReviewIntroHookConfig | undefined>(
() => wizardConfig.value?.structure?.review_intro_hook, () => wizardConfig.value?.structure?.review_intro_hook,
); );
@@ -546,6 +549,11 @@ watch(selectedBrandId, async (brandId) => {
return; return;
} }
if (!brandId) { if (!brandId) {
competitorDrafts.value = competitorDrafts.value.map((item) => ({
...item,
libraryId: undefined,
saved: false,
}));
return; return;
} }
@@ -881,6 +889,11 @@ function removeCompetitorRow(key: string): void {
} }
async function handleFavoriteCompetitor(item: DraftCompetitor): Promise<void> { async function handleFavoriteCompetitor(item: DraftCompetitor): Promise<void> {
const brandId = selectedBrandId.value;
if (!brandId) {
message.warning(t("templates.wizard.messages.selectBrandBeforeFavorite"));
return;
}
if (!item.name.trim()) { if (!item.name.trim()) {
message.warning(t("templates.wizard.messages.missingCompetitorName")); message.warning(t("templates.wizard.messages.missingCompetitorName"));
return; return;
@@ -889,7 +902,6 @@ async function handleFavoriteCompetitor(item: DraftCompetitor): Promise<void> {
competitorSavingKey.value = item.key; competitorSavingKey.value = item.key;
try { try {
const brandId = await ensureBrandForLibrary();
const payload = { const payload = {
name: item.name.trim(), name: item.name.trim(),
website: item.website.trim() || null, website: item.website.trim() || null,
@@ -917,21 +929,6 @@ async function handleFavoriteCompetitor(item: DraftCompetitor): Promise<void> {
} }
} }
async function ensureBrandForLibrary(): Promise<number> {
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[] { function buildCompetitorPayload(): TemplateAssistCompetitor[] {
if (!showCompetitorsCard.value) { if (!showCompetitorsCard.value) {
return []; return [];
@@ -1161,6 +1158,10 @@ function addCustomOutlineSection(): void {
if (!label) { if (!label) {
return; return;
} }
if (Array.from(label).length > customOutlineMaxLength.value) {
message.warning(t("templates.wizard.messages.customOutlineTooLong", { count: customOutlineMaxLength.value }));
return;
}
const key = `custom-${Date.now()}`; const key = `custom-${Date.now()}`;
customOutlineSections.value = [ customOutlineSections.value = [
@@ -1277,53 +1278,77 @@ function serializeOutlinePayloadNode(node: TemplateOutlineNode): JsonValue {
} }
function addOutlineRootNode(): void { function addOutlineRootNode(): void {
const node = createEmptyOutlineNode();
generatedOutline.value = [ generatedOutline.value = [
...generatedOutline.value, ...generatedOutline.value,
{ id: nextOutlineNodeKey(), outline: "", children: [] }, node,
]; ];
focusOutlineNode(node.id);
} }
function addOutlineNodeAfter(targetId: string): void { 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 { 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 { function removeOutlineNode(targetId: string): void {
generatedOutline.value = deleteOutlineNode(generatedOutline.value, targetId); 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<void> {
await nextTick();
window.requestAnimationFrame(() => {
const target = document.querySelector<HTMLElement>(`[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[] = []; const next: OutlineDraftNode[] = [];
for (const node of nodes) { for (const node of nodes) {
const current: OutlineDraftNode = { const current: OutlineDraftNode = {
...node, ...node,
children: insertOutlineSibling(node.children, targetId), children: insertOutlineSibling(node.children, targetId, insertedNode),
}; };
next.push(current); next.push(current);
if (node.id === targetId) { if (node.id === targetId) {
next.push({ id: nextOutlineNodeKey(), outline: "", children: [] }); next.push(insertedNode);
} }
} }
return next; return next;
} }
function insertOutlineChild(nodes: OutlineDraftNode[], targetId: string): OutlineDraftNode[] { function insertOutlineChild(
nodes: OutlineDraftNode[],
targetId: string,
insertedNode: OutlineDraftNode,
): OutlineDraftNode[] {
return nodes.map((node) => { return nodes.map((node) => {
if (node.id === targetId) { if (node.id === targetId) {
return { return {
...node, ...node,
children: [ children: [...node.children, insertedNode],
...node.children,
{ id: nextOutlineNodeKey(), outline: "", children: [] },
],
}; };
} }
return { return {
...node, ...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')" :placeholder="t('templates.wizard.placeholders.competitorDescription')"
/> />
<div class="competitor-actions"> <div class="competitor-actions">
<a-button <a-tooltip
type="link" :title="
:loading="competitorSavingKey === item.key" canSaveCompetitorsToLibrary
@click="handleFavoriteCompetitor(item)" ? undefined
: t('templates.wizard.messages.selectBrandBeforeFavorite')
"
> >
<template v-if="item.saved" #icon><StarFilled /></template> <span>
{{ item.saved ? t("templates.wizard.actions.saved") : t("templates.wizard.actions.favorite") }} <a-button
</a-button> type="link"
:disabled="!canSaveCompetitorsToLibrary"
:loading="competitorSavingKey === item.key"
@click="handleFavoriteCompetitor(item)"
>
<template v-if="canSaveCompetitorsToLibrary && item.saved" #icon><StarFilled /></template>
{{
canSaveCompetitorsToLibrary && item.saved
? t("templates.wizard.actions.saved")
: t("templates.wizard.actions.favorite")
}}
</a-button>
</span>
</a-tooltip>
<a-button type="text" danger @click="removeCompetitorRow(item.key)"> <a-button type="text" danger @click="removeCompetitorRow(item.key)">
<template #icon><DeleteOutlined /></template> <template #icon><DeleteOutlined /></template>
</a-button> </a-button>
@@ -2280,7 +2320,8 @@ function onStructureDragEnd(): void {
<div class="custom-outline-row"> <div class="custom-outline-row">
<a-input <a-input
v-model:value="customOutlineInput" v-model:value="customOutlineInput"
:maxlength="wizardConfig?.outline?.custom_max_length" :maxlength="customOutlineMaxLength"
show-count
:placeholder=" :placeholder="
wizardConfig?.outline?.custom_placeholder || wizardConfig?.outline?.custom_placeholder ||
t('templates.wizard.placeholders.customOutline') t('templates.wizard.placeholders.customOutline')
@@ -2360,6 +2401,7 @@ function onStructureDragEnd(): void {
<a-textarea <a-textarea
v-model:value="section.outline" v-model:value="section.outline"
class="outline-input" class="outline-input"
:data-outline-node-id="section.id"
:auto-size="{ minRows: 1 }" :auto-size="{ minRows: 1 }"
:placeholder="t('templates.wizard.placeholders.outlineNode')" :placeholder="t('templates.wizard.placeholders.outlineNode')"
/> />
@@ -2395,6 +2437,7 @@ function onStructureDragEnd(): void {
<a-textarea <a-textarea
v-model:value="child.outline" v-model:value="child.outline"
class="outline-input" class="outline-input"
:data-outline-node-id="child.id"
:auto-size="{ minRows: 1 }" :auto-size="{ minRows: 1 }"
:placeholder="t('templates.wizard.placeholders.outlineNode')" :placeholder="t('templates.wizard.placeholders.outlineNode')"
/> />