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
+80 -37
View File
@@ -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')"
/>