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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user