feat(keywords): rename brand-question terminology to search-keyword + add entity exclusion

- Rename UI/prompt labels: "品牌主问题" → "优化关键词", "搜索问题" → "搜索词"
- Add brand/competitor entity exclusion filter in question expansion (AI distill path)
- Refactor combination question text normalization, remove manual question-mark appending
- Update prompts to emphasize commercial-intent search queries over editorial phrasing
- Sync prompt changes across server/configs, deploy, and k3s configs
- Update i18n, frontend views (BrandQuestionCreate, TemplateWizard), and test fixtures

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 01:27:14 +08:00
parent 28633cf570
commit c89cad6643
19 changed files with 1211 additions and 668 deletions
@@ -51,7 +51,9 @@ let aiCandidateCloseTimer: number | null = null
const vFocus = {
mounted(el: HTMLElement) {
const input =
el instanceof HTMLInputElement ? el : el.querySelector<HTMLInputElement>('input')
el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement
? el
: el.querySelector<HTMLInputElement | HTMLTextAreaElement>('input, textarea')
if (input) {
input.focus()
const len = input.value.length
@@ -333,11 +335,11 @@ const materializeMutation = useMutation({
function splitLines(value: string | string[]): string[] {
if (Array.isArray(value)) {
return value.map((item) => item.trim()).filter(Boolean)
return value.map(normalizeSearchTermText).filter(Boolean)
}
return value
.split(/\r?\n|,/)
.map((item) => item.trim())
.map(normalizeSearchTermText)
.filter(Boolean)
}
@@ -494,30 +496,47 @@ function combinationPartCount(value: string | string[]): number {
}
function normalizeQuestionKey(value: string): string {
return value.trim().toLowerCase()
return normalizeSearchTermText(value).toLowerCase()
}
function normalizeSearchTermText(value: string): string {
return value
.trim()
.replace(/[\p{P}]/gu, '')
.replace(/\s+/g, '')
}
function questionLooksLikeQuestion(value: string): boolean {
const text = value.trim()
const text = normalizeSearchTermText(value)
if (text.length < 4) {
return false
}
return /[?]|什么|如何|怎么|哪|为什么|是否|能不能|适合|区别|对比|推荐/.test(text)
return /什么|如何|怎么|哪|为什么|是否|能不能|适合|区别|对比|推荐/.test(text)
}
function toCandidateRows(candidates: QuestionCandidate[]): CandidateRow[] {
return candidates.map((candidate, index) => ({
...candidate,
id: `${Date.now()}-${index}-${candidate.text}`,
selected: false,
}))
return candidates
.map((candidate, index) => {
const text = normalizeSearchTermText(candidate.text)
const tooShort = text.length < 4
const invalid = !questionLooksLikeQuestion(text)
return {
...candidate,
text,
too_short: candidate.too_short || tooShort,
suggest_skip: candidate.suggest_skip || tooShort || invalid,
id: `${Date.now()}-${index}-${text}`,
selected: false,
}
})
.filter((candidate) => candidate.text)
}
function buildLocalCandidates(texts: string[], source: QuestionSource): CandidateRow[] {
const seen = new Set<string>()
const rows: CandidateRow[] = []
for (const raw of texts) {
const text = raw.trim()
const text = normalizeSearchTermText(raw)
if (!text) {
continue
}
@@ -656,7 +675,7 @@ function removeCandidate(id: string): void {
}
function refreshCandidate(row: CandidateRow): void {
row.text = row.text.trim()
row.text = normalizeSearchTermText(row.text)
const key = normalizeQuestionKey(row.text)
const duplicateInBatch = candidateRows.value.some(
(item) => item.id !== row.id && normalizeQuestionKey(item.text) === key,
@@ -985,10 +1004,12 @@ function backToBrands(): void {
:disabled="isCandidateSelectionDisabled(row)"
@change="(event: Event) => updateCandidateSelection(row, (event.target as HTMLInputElement).checked)"
/>
<a-input
<a-textarea
v-if="editingCandidateId === row.id"
v-focus
v-model:value="row.text"
class="candidate-row__editor"
auto-size
@blur="stopEditingCandidate(row)"
@keydown.enter="stopEditingCandidate(row)"
/>
@@ -999,13 +1020,13 @@ function backToBrands(): void {
>
{{ row.text }}
</span>
<a-tag v-if="row.duplicate" color="orange">
<a-tag v-if="editingCandidateId !== row.id && row.duplicate" color="orange">
{{ t('brands.questions.badges.duplicate') }}
</a-tag>
<a-tag v-else-if="row.too_short" color="red">
<a-tag v-else-if="editingCandidateId !== row.id && row.too_short" color="red">
{{ t('brands.questions.badges.tooShort') }}
</a-tag>
<div class="candidate-row__actions">
<div v-if="editingCandidateId !== row.id" class="candidate-row__actions">
<a-button type="text" shape="circle" class="action-btn-edit" @click="startEditingCandidate(row.id)">
<EditOutlined />
</a-button>
@@ -1533,6 +1554,24 @@ function backToBrands(): void {
word-break: break-all;
}
.candidate-row__editor {
width: 100%;
min-width: 0;
min-height: 44px;
line-height: 1.6;
white-space: pre-wrap;
resize: none;
}
.candidate-row__editor :deep(.ant-input) {
width: 100%;
min-height: 44px;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
resize: none;
}
.candidate-row__actions {
display: flex;
gap: 4px;
+31 -19
View File
@@ -286,6 +286,12 @@ const templateMeta = computed(() => {
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) {
@@ -654,6 +660,23 @@ 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
@@ -1192,8 +1215,9 @@ function sanitizeLegacyKeywordCopy(copy: TemplateCardCopy): TemplateCardCopy {
function sanitizeLegacyKeywordText(value: string | undefined): string {
return (value || '')
.replace(/核心关键词/g, '品牌主问题')
.replace(/关键词/g, '品牌问题')
.replace(/品牌主问题/g, '优化关键词')
.replace(/补充覆盖问题/g, '补充关键词')
.replace(/核心关键词/g, '优化关键词')
.replace(/标题生成/g, '后续生成')
}
@@ -2392,29 +2416,17 @@ function onStructureDragEnd(): void {
/>
</div>
<div class="field-item">
<label>{{ t('templates.wizard.sections.brandLibrary') }}</label>
<a-select
v-model:value="selectedBrandId"
allow-clear
show-search
:placeholder="t('templates.wizard.placeholders.brandLibrary')"
:options="
(brandsQuery.data.value || []).map((item) => ({
label: item.name,
value: item.id,
}))
"
/>
</div>
<div class="field-item">
<label class="required-asterisk">
{{ t('templates.wizard.sections.brandName') }}
</label>
<a-input
<a-auto-complete
v-model:value="brandName"
allow-clear
:options="brandNameOptions"
:placeholder="t('templates.wizard.placeholders.brandName')"
@change="handleBrandNameInput"
@select="handleBrandNameSelect"
/>
</div>
+1 -1
View File
@@ -862,7 +862,7 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
/>
</div>
<div class="tracking-action-item">
<span class="tracking-action-label">问题集</span>
<span class="tracking-action-label">关键词库搜索词</span>
<a-select
v-model:value="selectedQuestionSetOptionValue"
show-search