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:
@@ -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.",
|
||||
|
||||
@@ -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 分析已完成,可以继续调整内容。",
|
||||
|
||||
@@ -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<TemplateReviewIntroHookConfig | undefined>(
|
||||
() => 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<void> {
|
||||
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<void> {
|
||||
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<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[] {
|
||||
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<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[] = [];
|
||||
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')"
|
||||
/>
|
||||
<div class="competitor-actions">
|
||||
<a-button
|
||||
type="link"
|
||||
:loading="competitorSavingKey === item.key"
|
||||
@click="handleFavoriteCompetitor(item)"
|
||||
<a-tooltip
|
||||
:title="
|
||||
canSaveCompetitorsToLibrary
|
||||
? undefined
|
||||
: t('templates.wizard.messages.selectBrandBeforeFavorite')
|
||||
"
|
||||
>
|
||||
<template v-if="item.saved" #icon><StarFilled /></template>
|
||||
{{ item.saved ? t("templates.wizard.actions.saved") : t("templates.wizard.actions.favorite") }}
|
||||
</a-button>
|
||||
<span>
|
||||
<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)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
@@ -2280,7 +2320,8 @@ function onStructureDragEnd(): void {
|
||||
<div class="custom-outline-row">
|
||||
<a-input
|
||||
v-model:value="customOutlineInput"
|
||||
:maxlength="wizardConfig?.outline?.custom_max_length"
|
||||
:maxlength="customOutlineMaxLength"
|
||||
show-count
|
||||
:placeholder="
|
||||
wizardConfig?.outline?.custom_placeholder ||
|
||||
t('templates.wizard.placeholders.customOutline')
|
||||
@@ -2360,6 +2401,7 @@ function onStructureDragEnd(): void {
|
||||
<a-textarea
|
||||
v-model:value="section.outline"
|
||||
class="outline-input"
|
||||
:data-outline-node-id="section.id"
|
||||
:auto-size="{ minRows: 1 }"
|
||||
:placeholder="t('templates.wizard.placeholders.outlineNode')"
|
||||
/>
|
||||
@@ -2395,6 +2437,7 @@ function onStructureDragEnd(): void {
|
||||
<a-textarea
|
||||
v-model:value="child.outline"
|
||||
class="outline-input"
|
||||
:data-outline-node-id="child.id"
|
||||
:auto-size="{ minRows: 1 }"
|
||||
:placeholder="t('templates.wizard.placeholders.outlineNode')"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user