feat: scope articles by brand

This commit is contained in:
2026-05-20 15:37:25 +08:00
parent 5fb9d0b0dd
commit dd082e2ed1
72 changed files with 3213 additions and 432 deletions
+67 -65
View File
@@ -26,6 +26,7 @@ import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue'
import { articlesApi, brandsApi, normalizeInputParams, templatesApi } from '@/lib/api'
import { getTemplateMeta } from '@/lib/display'
import { formatError } from '@/lib/errors'
import { useCompanyStore } from '@/stores/company'
interface DraftCompetitor {
key: string
@@ -151,6 +152,7 @@ const queryClient = useQueryClient()
const { locale: uiLocale, t } = useI18n()
const route = useRoute()
const router = useRouter()
const companyStore = useCompanyStore()
let competitorKeySeed = 0
let outlineNodeSeed = 0
@@ -167,8 +169,6 @@ const savingDraft = ref(false)
const leaveConfirmOpen = ref(false)
const leaveConfirmBusy = ref(false)
const selectedBrandId = ref<number | null>(null)
const brandName = ref('')
const officialWebsite = ref('')
const brandSummary = ref('')
@@ -209,11 +209,9 @@ const templateDetailQuery = useQuery({
queryFn: () => templatesApi.detail(templateId.value),
})
const brandsQuery = useQuery({
queryKey: ['brands', 'wizard-context'],
enabled,
queryFn: () => brandsApi.list(),
})
const selectedBrand = computed(() => companyStore.currentBrand)
const selectedBrandId = computed(() => selectedBrand.value?.id ?? null)
const normalizedBrandName = computed(() => selectedBrand.value?.name?.trim() || '')
const draftArticleQuery = useQuery({
queryKey: computed(() => ['articles', 'detail', draftArticleId.value]),
@@ -283,15 +281,6 @@ const templateMeta = computed(() => {
helper: templateCardConfig.value.hero?.helper || fallback.helper,
}
})
const selectedBrand = computed(
() => brandsQuery.data.value?.find((brand) => brand.id === selectedBrandId.value) ?? null,
)
const brandNameOptions = computed(() =>
(brandsQuery.data.value ?? []).map((item) => ({
label: item.name,
value: item.name,
})),
)
function findQuestionById(id: number | null): Question | null {
if (!id) {
@@ -323,9 +312,6 @@ const selectedQuestionTexts = computed(() =>
dedupeStrings([primaryQuestionText.value, ...supplementalQuestionTexts.value]),
)
const normalizedBrandName = computed(
() => brandName.value.trim() || selectedBrand.value?.name || '',
)
const finalTitle = computed(() => customTitle.value.trim() || selectedTitle.value.trim())
const primaryKeyword = computed(() => primaryQuestionText.value)
const showCompetitorsCard = computed(
@@ -455,7 +441,6 @@ const isPageLoading = computed(
const hasWizardDraftContent = computed(
() =>
Boolean(selectedBrandId.value) ||
Boolean(brandName.value.trim()) ||
Boolean(officialWebsite.value.trim()) ||
Boolean(brandSummary.value.trim()) ||
Boolean(primaryQuestionId.value) ||
@@ -504,10 +489,7 @@ function resetWizardState(detail: NonNullable<typeof templateDetail.value>): voi
currentArticleId.value = draftArticleId.value
currentStep.value = 0
articleLocale.value = 'zh-CN'
selectedBrandId.value = null
brandName.value = ''
officialWebsite.value = ''
brandSummary.value = ''
hydrateBrandContextFromCurrentBrand()
primaryQuestionId.value = null
supplementalQuestionIds.value = []
competitorDrafts.value = []
@@ -526,9 +508,9 @@ function resetWizardState(detail: NonNullable<typeof templateDetail.value>): voi
detail.template_key,
buildTemplateRenderContext({
locale: 'zh-CN',
brandName: '',
officialWebsite: '',
brandSummary: '',
brandName: normalizedBrandName.value,
officialWebsite: officialWebsite.value,
brandSummary: brandSummary.value,
primaryQuestion: '',
supplementalQuestions: [],
inputParams: {},
@@ -542,9 +524,9 @@ function resetWizardState(detail: NonNullable<typeof templateDetail.value>): voi
detail.template_key,
buildTemplateRenderContext({
locale: 'zh-CN',
brandName: '',
officialWebsite: '',
brandSummary: '',
brandName: normalizedBrandName.value,
officialWebsite: officialWebsite.value,
brandSummary: brandSummary.value,
primaryQuestion: '',
supplementalQuestions: [],
inputParams: {},
@@ -565,10 +547,9 @@ function applyDraftArticleState(detail: ArticleDetail): void {
currentStep.value = clampStep(numberFromUnknown(draftState.current_step))
articleLocale.value = stringFromUnknown(draftState.locale) || articleLocale.value
selectedBrandId.value = numberFromUnknown(draftState.selected_brand_id) ?? null
brandName.value = stringFromUnknown(draftState.brand_name) || ''
officialWebsite.value = stringFromUnknown(draftState.official_website) || ''
brandSummary.value = stringFromUnknown(draftState.brand_summary) || ''
hydrateBrandContextFromCurrentBrand()
officialWebsite.value = stringFromUnknown(draftState.official_website) || officialWebsite.value
brandSummary.value = stringFromUnknown(draftState.brand_summary) || brandSummary.value
primaryQuestionId.value = numberFromUnknown(draftState.primary_question_id) ?? null
supplementalQuestionIds.value =
numberListFromUnknown(draftState.supplemental_question_ids)?.slice(0, 3) ?? []
@@ -604,6 +585,14 @@ watch(
{ immediate: true },
)
watch(
() => selectedBrand.value,
() => {
hydrateBrandContextFromCurrentBrand()
},
{ immediate: true },
)
watch(
baseOutlineOptions,
(options) => {
@@ -660,23 +649,6 @@ watch(supplementalQuestionIds, (ids) => {
}
})
function handleBrandNameInput(value: string): void {
brandName.value = value
const matchedBrand = brandsQuery.data.value?.find((item) => item.name === value)
selectedBrandId.value = matchedBrand?.id ?? null
}
function handleBrandNameSelect(value: string): void {
const brand = brandsQuery.data.value?.find((item) => item.name === value)
if (!brand) {
selectedBrandId.value = null
brandName.value = value
return
}
selectedBrandId.value = brand.id
brandName.value = brand.name
}
watch(selectedBrandId, async (brandId) => {
if (restoringDraft.value) {
return
@@ -690,10 +662,7 @@ watch(selectedBrandId, async (brandId) => {
return
}
const brand = brandsQuery.data.value?.find((item) => item.id === brandId)
if (brand && !brandName.value.trim()) {
brandName.value = brand.name
}
const brand = selectedBrand.value
if (brand?.website && !officialWebsite.value.trim()) {
officialWebsite.value = brand.website
}
@@ -719,7 +688,13 @@ watch(selectedBrandId, async (brandId) => {
description: item.description ?? '',
saved: true,
}))
})
}, { immediate: true })
function hydrateBrandContextFromCurrentBrand(): void {
const brand = selectedBrand.value
officialWebsite.value = brand?.website ?? ''
brandSummary.value = brand?.description ?? ''
}
onBeforeRouteLeave(async () => {
if (!shouldConfirmBeforeLeavingWizard()) {
@@ -1310,7 +1285,7 @@ function buildDraftState(): Record<string, JsonValue> {
current_step: currentStep.value,
locale: articleLocale.value,
selected_brand_id: selectedBrandId.value,
brand_name: brandName.value.trim(),
brand_name: normalizedBrandName.value,
official_website: officialWebsite.value.trim(),
brand_summary: brandSummary.value.trim(),
primary_question_id: primaryQuestionId.value,
@@ -2420,14 +2395,10 @@ function onStructureDragEnd(): void {
<label class="required-asterisk">
{{ t('templates.wizard.sections.brandName') }}
</label>
<a-auto-complete
v-model:value="brandName"
allow-clear
:options="brandNameOptions"
:placeholder="t('templates.wizard.placeholders.brandName')"
@change="handleBrandNameInput"
@select="handleBrandNameSelect"
/>
<div class="current-brand-field">
<strong>{{ normalizedBrandName || t('shell.currentCompanyPlaceholder') }}</strong>
<span v-if="selectedBrand?.website">{{ selectedBrand.website }}</span>
</div>
</div>
<div class="field-item">
@@ -3201,6 +3172,37 @@ function onStructureDragEnd(): void {
content: '';
}
.current-brand-field {
display: flex;
min-height: 32px;
flex-direction: column;
justify-content: center;
gap: 2px;
padding: 5px 11px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fafcff;
}
.current-brand-field strong {
overflow: hidden;
color: #111827;
font-size: 14px;
font-weight: 600;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.current-brand-field span {
overflow: hidden;
color: #667085;
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.optional-field-label {
display: flex;
align-items: center;