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.",
|
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.",
|
||||||
|
|||||||
@@ -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 分析已完成,可以继续调整内容。",
|
||||||
|
|||||||
@@ -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')"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user