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
@@ -29,6 +29,7 @@ import {
insertHrCommand,
toggleEmphasisCommand,
toggleStrongCommand,
turnIntoTextCommand,
wrapInBlockquoteCommand,
wrapInBulletListCommand,
wrapInHeadingCommand,
@@ -2266,6 +2267,7 @@ function runTableContextAction(action: TableContextMenuAction): void {
<template #icon><IconFont type="icon-redo" /></template>
</a-button>
<span class="article-editor-canvas__divider"></span>
<a-button size="small" type="text" @click="runCommand(turnIntoTextCommand)">正文</a-button>
<a-button size="small" type="text" @click="runCommand(wrapInHeadingCommand, 1)">H1</a-button>
<a-button size="small" type="text" @click="runCommand(wrapInHeadingCommand, 2)">H2</a-button>
<a-button size="small" type="text" @click="runCommand(wrapInHeadingCommand, 3)">H3</a-button>
+76 -69
View File
@@ -154,7 +154,7 @@ const enUS = {
template_outline_generate: 'AI Outline Generation',
kol_prompt_generate: 'KOL Prompt Generation',
kol_prompt_optimize: 'KOL Prompt Optimize',
question_distill: 'AI Candidate Question Generation',
question_distill: 'AI Candidate Search Term Generation',
question_combination_fill: 'Expansion Tool AI Fill',
},
aiUsageStatus: {
@@ -214,12 +214,13 @@ const enUS = {
'Review desktop publish tasks, external links, account workbench entry points, and retry state.',
},
brands: {
title: 'Brand Library',
description: 'Manage keywords, question sets, and competitors around each brand.',
title: 'Company & Lexicon',
description: 'Manage the keyword library (search terms) and competitors around each brand.',
},
brandQuestionCreate: {
title: 'New question',
description: 'Create brand questions with the expansion tool, AI expansion, or manual input.',
title: 'New search term',
description:
'Create brand search terms with the expansion tool, AI expansion, or manual input.',
},
tracking: {
title: 'Data Details',
@@ -459,11 +460,11 @@ const enUS = {
},
tracking: {
brandPlaceholder: 'Select brand',
keywordPlaceholder: 'Select question set',
allQuestionSets: 'All question sets',
keywordPlaceholder: 'Select keyword library (search terms)',
allQuestionSets: 'All keyword library (search terms)',
collectNow: 'Collect Now',
collectBrandRequired: 'Select a brand before collecting.',
collectKeywordRequired: 'Select a question set before collecting.',
collectKeywordRequired: 'Select a keyword library (search terms) before collecting.',
collectTodayOnly: "Collect Now only supports today's data. Switch back to today to continue.",
collectClientChecking: "Checking whether the current account's desktop client is online.",
collectClientOnlineRequired:
@@ -488,7 +489,7 @@ const enUS = {
hotQuestions: 'Hot Questions',
hotQuestionsTitle: 'Hot Questions',
hotQuestionsHint:
'Choose all question sets or one specific question. Selecting one question limits display and collection to that question.',
'Choose the full keyword library (search terms) or one specific search term. Selecting one search term limits display and collection to that term.',
questionSearchPlaceholder: 'Search questions in the selected scope',
questionDetailEyebrow: 'Question Detail',
questionDetailCollectedAt: 'Collected at {time}',
@@ -841,13 +842,13 @@ const enUS = {
language: 'Language',
brandInfo: 'Brand',
brandLibrary: 'Brand library',
brandName: 'Brand name',
brandName: 'Company name',
website: 'Official website',
brandSummary: 'AI brand summary',
keywords: 'Keywords',
brandQuestions: 'Brand questions',
primaryQuestion: 'Primary brand question',
supplementalQuestions: 'Supplemental questions',
brandQuestions: 'Keyword library (search terms)',
primaryQuestion: 'Optimization keyword',
supplementalQuestions: 'Supplemental keywords',
competitors: 'Competitors',
chooseTitle: 'Choose a title',
customTitle: 'Custom title',
@@ -864,17 +865,17 @@ const enUS = {
hints: {
brandContext: 'Optional. Selecting a brand pulls in keywords and competitor context.',
brandFlow:
'You can pick a brand from the library or type one manually. After you choose a brand question, AI will prepare the summary, competitors, and title options.',
'You can type a company name or choose one from the brand library. After you choose an optimization keyword, AI will prepare the summary, competitors, and title options.',
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.',
brandQuestions:
'The primary question controls the article spine; supplemental questions broaden nearby search intent.',
'The optimization keyword controls the article spine; supplemental keywords broaden nearby search intent.',
supplementalQuestions:
'Choose up to 3 so the article covers related intent without drifting.',
questionEmpty: 'No questions are available for this brand yet.',
selectBrandFirst: 'Choose a brand from the library first.',
selectBrandFirst: 'Choose a library brand from the company name dropdown first.',
competitors:
'Competitors stay editable. You can add rows manually; saving to the competitor library is only available after choosing a library brand.',
competitorEmptyEditable:
@@ -931,11 +932,11 @@ const enUS = {
},
placeholders: {
brandLibrary: 'Choose an existing brand from the library',
brandName: 'Enter a brand name, or refine the selected library brand',
brandName: 'Enter a company name, or choose from the brand library',
website: 'Official website, optional',
keywords: 'Type keywords and press Enter',
primaryQuestion: 'Choose one primary brand question',
supplementalQuestions: 'Optional, choose up to 3 supplemental questions',
primaryQuestion: 'Choose one optimization keyword',
supplementalQuestions: 'Optional, choose up to 3 supplemental keywords',
customTitle: 'Override the AI title here if needed',
customOutline: 'Add another section, for example “Buying advice”',
keyPoints: 'Add any extra instructions, key angles, or constraints for the article',
@@ -949,9 +950,9 @@ const enUS = {
aiBanner: {
title: 'One-click AI analysis',
defaultDescription:
'AI will use the brand question to complete the brand summary and extract competitor context.',
'AI will use the optimization keyword to complete the brand summary and extract competitor context.',
productReviewDescription:
'AI will use the brand question to organize the product context and suggest comparable alternatives.',
'AI will use the optimization keyword to organize the product context and suggest comparable alternatives.',
researchReportDescription:
'AI will use the brand question to organize the topic context and suggest reference targets.',
},
@@ -983,15 +984,18 @@ const enUS = {
},
messages: {
requiredField: 'Please fill in {field} first.',
missingBrand: 'Choose a brand from the library or enter a brand name first.',
selectBrandBeforeQuestion: 'Choose a brand from the library before selecting a question.',
missingBrand: 'Enter or choose a company name first.',
selectBrandBeforeQuestion:
'Choose a library brand from the company name dropdown before selecting a question.',
missingPrimaryQuestion: 'Choose one primary brand question.',
missingTitle: 'Please choose or enter an article title.',
missingOutline: 'Please select at least one outline section.',
missingGeneratedOutline: 'Please generate and confirm the article outline first.',
customOutlineTooLong: 'Custom sections cannot exceed {count} characters.',
selectBrandBeforeCompetitors: 'Choose a brand from the library before managing competitors.',
selectBrandBeforeFavorite: 'Choose a brand from the library before saving competitors.',
selectBrandBeforeCompetitors:
'Choose a library brand from the company name dropdown before managing competitors.',
selectBrandBeforeFavorite:
'Choose a library brand from the company name dropdown before saving competitors.',
missingCompetitorName: 'Enter a competitor name before saving it to the library.',
savedCompetitor: 'Competitor saved to the brand library.',
unsavedCompetitor: 'Competitor removed from the brand library.',
@@ -1285,20 +1289,21 @@ const enUS = {
},
},
brands: {
eyebrow: 'Brand Library',
title: 'Brand Management',
description: 'Manage question sets and competitor assets around each brand.',
eyebrow: 'Company & Lexicon',
title: 'Company & Lexicon',
description: 'Manage the keyword library (search terms) and competitor assets around each brand.',
railTitle: 'Brands',
selectBrand: 'Pick a brand to continue managing question sets.',
selectBrand: 'Pick a brand to continue managing the keyword library (search terms).',
newBrand: 'New brand',
editBrand: 'Edit brand',
deleteBrand: 'Delete brand',
guide: {
title: 'Brand management guide',
body: 'Manage brands, question sets, and competitor libraries for AI platform monitoring and GEO analysis.',
body:
'Manage brands, the keyword library (search terms), and competitor libraries for AI platform monitoring and GEO analysis.',
},
tabs: {
questions: 'Question set',
questions: 'Keyword library (search terms)',
competitors: 'Competitors',
},
quota: {
@@ -1321,20 +1326,20 @@ const enUS = {
},
sections: {
keywords: 'Keywords',
questions: 'Question set',
questions: 'Keyword library (search terms)',
competitors: 'Competitor library',
versions: 'Version history',
},
actions: {
addBrand: 'Add brand',
newKeyword: 'New keyword',
newQuestion: 'New question',
newQuestion: 'New search term',
newCompetitor: 'New competitor',
viewVersions: 'View versions',
},
empty: {
brands: 'No brand data yet. Create your first brand to start.',
questions: 'No questions under this brand yet.',
questions: 'No search terms under this brand yet.',
competitors: 'No competitors under this brand yet.',
brandDescription: 'No brand description yet',
competitorDescription: 'No competitor description yet',
@@ -1344,7 +1349,7 @@ const enUS = {
brandWebsite: 'Company website (optional)',
brandDescription: 'Brand description',
keywordName: 'Keyword',
questionText: 'Question text',
questionText: 'Search term',
competitorName: 'Competitor name',
competitorWebsite: 'Competitor website',
competitorDescription: 'Competitor description',
@@ -1358,9 +1363,9 @@ const enUS = {
createKeyword: 'Keyword created.',
updateKeyword: 'Keyword updated.',
deleteKeyword: 'Keyword deleted.',
createQuestion: 'Question created.',
updateQuestion: 'Question updated.',
deleteQuestion: 'Question deleted.',
createQuestion: 'Search term created.',
updateQuestion: 'Search term updated.',
deleteQuestion: 'Search term deleted.',
createCompetitor: 'Competitor created.',
updateCompetitor: 'Competitor updated.',
deleteCompetitor: 'Competitor deleted.',
@@ -1368,21 +1373,21 @@ const enUS = {
chooseKeyword: 'Please choose a keyword first.',
brandLimitReached: 'Your current plan allows up to {limit} brand companies.',
keywordLimitReached: 'This account allows up to {limit} keywords.',
questionLimitReached: 'This account allows up to {limit} questions.',
questionLimitReached: 'This account allows up to {limit} search terms.',
},
questions: {
create: 'New question',
createSingle: 'Add single question',
expand: 'Bulk expand questions',
usage: 'Questions {used} / {total}',
create: 'New search term',
createSingle: 'Add single search term',
expand: 'Bulk expand search terms',
usage: 'Search terms {used} / {total}',
remaining: '{count} remaining',
generate: 'Generate candidates',
emptyCandidates: 'No candidates yet. Generate or paste questions above.',
emptyCandidates: 'No candidates yet. Generate or paste search terms above.',
table: {
question: 'Question set',
question: 'Keyword library (search terms)',
},
modal: {
title: 'New question',
title: 'New search term',
currentBrand: 'Current brand',
methods: {
single: 'Single',
@@ -1393,17 +1398,17 @@ const enUS = {
preview: 'Candidate preview',
summary:
'{total} candidates · {selected} selected · {tooShort} too short · {duplicate} duplicates',
save: 'Save questions ({count})',
save: 'Save search terms ({count})',
},
combination: {
region: '1. Region',
prefix: '2. Prefix',
core: '3. Core (required)',
core: '3. Precise term (required)',
industry: '4. Industry (required)',
suffix: '5. Suffix',
defaultWord: 'Default',
estimate: '{count} candidates estimated',
coreRequired: 'Core is required',
coreRequired: 'Precise term is required',
industryRequired: 'Industry is required',
},
aiFill: {
@@ -1414,25 +1419,25 @@ const enUS = {
waitingStages: {
region: 'Detecting city, province capital, and macro region...',
generate: 'Analyzing brand context and high-frequency intent...',
refine: 'Refining short combinable keyword groups...',
refine: 'Refining short combinable precise term groups...',
},
},
ai: {
note: 'AI expansion consumes 1 AI point. Failed generations are refunded automatically.',
seedTopic: 'Seed topic',
seedTopicPlaceholder: 'Enter a topic and press Enter; multiple topics supported',
seedTopic: 'User search term',
seedTopicPlaceholder: 'Enter a search term and press Enter; multiple terms supported',
seedTopicHint:
'Use Enter, commas, or semicolons to add multiple topics. Candidates are merged and deduplicated automatically.',
'Candidates expand from user search terms with strong commercial intent. The company name is a target term, not a candidate search term.',
generate: 'Generate with AI',
waitingTitle: 'AI is generating candidate questions. Please wait...',
waitingTitle: 'AI is generating candidate search terms. Please wait...',
waitingStages: {
context: 'Organizing brand and topic context...',
generate: 'Generating high-intent candidate questions...',
refine: 'Deduplicating and filtering savable questions...',
context: 'Organizing user search terms and the target term...',
generate: 'Generating commercial candidate search terms suited for recommending companies...',
refine: 'Deduplicating and filtering savable search terms...',
},
},
batch: {
label: 'One question per line',
label: 'One search term per line',
},
badges: {
duplicate: 'Duplicate',
@@ -1440,20 +1445,22 @@ const enUS = {
},
messages: {
partialSaved: 'Saved {created}; skipped {skipped}.',
limitReached: 'This account allows up to {limit} questions shared across all brands.',
selectionLimitReached: 'This plan allows up to {limit} questions. Selection limit reached.',
limitReached: 'This account allows up to {limit} search terms shared across all brands.',
selectionLimitReached:
'This plan allows up to {limit} search terms. Selection limit reached.',
truncated: 'Candidates exceeded the limit and were truncated to 500.',
cacheHit: 'Used the most recent AI expansion result.',
aiCharged: 'This AI expansion consumed {count} AI point.',
},
errors: {
emptyInput: 'Enter question content first.',
emptyInput: 'Enter search term content first.',
noSelection: 'Select at least one candidate.',
noValidQuestions: 'No valid questions can be saved. Adjust candidates and try again.',
noValidQuestions: 'No valid search terms can be saved. Adjust candidates and try again.',
},
page: {
title: 'New question',
subtitle: 'Generate candidates, review them, then save them to this brand question set.',
title: 'New search term',
subtitle:
'Generate candidates, review them, then save them to this brand keyword library (search terms).',
steps: {
configure: 'Configure',
preview: 'Review candidates',
@@ -1462,13 +1469,13 @@ const enUS = {
methodHint:
'Candidates remain temporary until saved, so you can generate, edit, and select first.',
methods: {
combination: 'Expand candidates from the column-based word tool.',
ai: 'Let AI generate candidates around a seed topic.',
batch: 'Paste questions from a sheet or document.',
combination: 'Expand search term candidates from the column-based word tool.',
ai: 'Let AI generate search term candidates around a seed topic.',
batch: 'Paste search terms from a sheet or document.',
},
expansionHint:
'One word per line. Empty optional columns are skipped; core and industry are required.',
batchHint: 'One question per line. You can edit and select each one before saving.',
'One word per line. Empty optional columns are skipped; precise term and industry are required.',
batchHint: 'One search term per line. You can edit and select each one before saving.',
},
},
},
+77 -77
View File
@@ -79,7 +79,7 @@ const zhCN = {
media: "媒体管理",
publishManagement: "发文管理",
brandManagement: "品牌管理",
brands: "品牌词库",
brands: "公司和词库",
tracking: "数据追踪",
trackingDetail: "数据详情",
contentManagement: "内容管理",
@@ -150,7 +150,7 @@ const zhCN = {
template_outline_generate: "AI 生成大纲",
kol_prompt_generate: "KOL 模版生成",
kol_prompt_optimize: "KOL 模版优化",
question_distill: "AI 生成候选问题",
question_distill: "AI 生成候选搜索词",
question_combination_fill: "拓词工具 AI 填词",
},
aiUsageStatus: {
@@ -206,12 +206,12 @@ const zhCN = {
description: "查看桌面端发布任务、外链、账号工作台入口和重发状态。",
},
brands: {
title: "品牌词库",
description: "围绕品牌维护关键词、问题集和竞品信息。",
title: "公司和词库",
description: "围绕品牌维护关键词库(搜索词)和竞品信息。",
},
brandQuestionCreate: {
title: "新建问题",
description: "用拓词工具、AI 扩展或手动录入创建品牌问题集。",
title: "新建搜索词",
description: "用拓词工具、AI 扩展或手动录入创建品牌关键词库(搜索词)。",
},
tracking: {
title: "数据详情",
@@ -440,11 +440,11 @@ const zhCN = {
},
tracking: {
brandPlaceholder: "选择品牌",
keywordPlaceholder: "选择问题集",
allQuestionSets: "全部问题集",
keywordPlaceholder: "选择关键词库(搜索词)",
allQuestionSets: "全部关键词库(搜索词)",
collectNow: "立即采集",
collectBrandRequired: "请先选择品牌,再发起采集",
collectKeywordRequired: "请选择问题集后再发起采集",
collectKeywordRequired: "请选择关键词库(搜索词)后再发起采集",
collectTodayOnly: "立即采集只支持今天的数据,请切回今天后再操作",
collectClientChecking: "正在检查当前账号的桌面客户端状态",
collectClientOnlineRequired: "当前登录账号的桌面客户端未在线,暂时不能立即采集",
@@ -464,7 +464,7 @@ const zhCN = {
platformMatrixTitle: "平台对比矩阵",
hotQuestions: "Hot Questions",
hotQuestionsTitle: "高频问题",
hotQuestionsHint: "可选择全部问题集或具体单个问题;选择单个问题时只展示并采集该问题。",
hotQuestionsHint: "可选择全部关键词库(搜索词)或具体单个搜索词;选择单个搜索词时只展示并采集该搜索词。",
questionSearchPlaceholder: "搜索当前范围下的问题",
questionDetailEyebrow: "Question Detail",
questionDetailCollectedAt: "采集时间 {time}",
@@ -806,13 +806,13 @@ const zhCN = {
language: "文章语言",
brandInfo: "品牌信息",
brandLibrary: "品牌库",
brandName: "品牌名称",
brandName: "公司名称",
website: "官网地址",
brandSummary: "AI 品牌摘要",
keywords: "关键词",
brandQuestions: "品牌问题",
primaryQuestion: "品牌主问题",
supplementalQuestions: "补充覆盖问题",
brandQuestions: "关键词库(搜索词)",
primaryQuestion: "优化关键词",
supplementalQuestions: "补充关键词",
competitors: "竞品列表",
chooseTitle: "选择文章标题",
customTitle: "自定义标题",
@@ -829,13 +829,13 @@ const zhCN = {
},
hints: {
brandContext: "可选。选择后会自动带出关键词与竞品信息。",
brandFlow: "品牌可直接输入,也可以从品牌库带入。选择品牌问题后,AI 会补充品牌摘要、竞品和标题建议。",
brandFlow: "公司名称可直接输入,也可以从品牌库选择。选择优化关键词后,AI 会补充品牌摘要、竞品和标题建议。",
brandSummaryPlaceholder: "AI 分析后会在这里补充品牌简介、差异点和适合切入的内容角度。",
keywords: "支持直接输入,也支持复用品牌词库里的关键词。AI 分析结果会自动补充到这里。",
brandQuestions: "主问题决定文章主线,补充覆盖问题只用于扩展相关搜索意图。",
brandQuestions: "优化关键词决定文章主线,补充关键词只用于扩展相关搜索意图。",
supplementalQuestions: "最多选择 3 个,用于覆盖相邻搜索问题,避免文章主题发散。",
questionEmpty: "当前品牌还没有可选问题",
selectBrandFirst: "请先选择品牌库对象",
selectBrandFirst: "请先从公司名称下拉选择品牌库对象",
competitors: "竞品支持手动编辑、追加;只有从品牌库选择品牌后,才可以收藏到品牌词库。AI 分析会尽量补齐候选竞品。",
competitorEmptyEditable: "还没有竞品,先手动添加一条或点击分析让 AI 自动补齐。",
templateFields: "这里保留当前模板自己的补充字段,用来约束 AI 生成角度。",
@@ -887,11 +887,11 @@ const zhCN = {
},
placeholders: {
brandLibrary: "从品牌库选择已有品牌",
brandName: "输入品牌名,或在选择品牌后继续微调",
brandName: "输入公司名称,或从品牌库选择",
website: "输入官网地址,选填",
keywords: "输入关键词后回车,可多选",
primaryQuestion: "选择一个品牌主问题",
supplementalQuestions: "可选,最多选择 3 个补充问题",
primaryQuestion: "选择一个优化关键词",
supplementalQuestions: "可选,最多选择 3 个补充关键词",
customTitle: "如需覆盖 AI 标题,可在这里直接输入",
customOutline: "补充一个额外结构,例如“选购建议”",
keyPoints: "告诉 AI 还需要强调哪些要点、风格或限制条件",
@@ -904,9 +904,9 @@ const zhCN = {
promptSealed: "Prompt 已封装",
aiBanner: {
title: "一键智能分析",
defaultDescription: "AI 将基于品牌问题补充品牌简介,并智能提取竞品信息",
productReviewDescription: "AI 将基于品牌问题梳理产品背景,并生成同类替代方案参考",
researchReportDescription: "AI 将基于品牌问题梳理主题背景,并生成参照对象参考",
defaultDescription: "AI 将基于优化关键词补充品牌简介,并智能提取竞品信息",
productReviewDescription: "AI 将基于优化关键词梳理产品背景,并生成同类替代方案参考",
researchReportDescription: "AI 将基于优化关键词梳理主题背景,并生成参照对象参考",
},
assist: {
analyzeTitle: "AI 正在补齐竞品信息,请稍后…",
@@ -916,36 +916,36 @@ const zhCN = {
analyzeStages: {
brand: "品牌信息分析中…",
keywords: "关键词提炼中…",
questions: "品牌问题整理中…",
questions: "关键词库(搜索词)整理中…",
competitors: "竞品与官网信息补充中…",
context: "正在整理品牌与官网上下文…",
refine: "正在合并并筛选竞品列表…",
},
titleStages: {
context: "正在整理当前内容上下文…",
strategy: "正在结合品牌问题和竞品生成标题策略…",
strategyNoCompetitors: "正在结合品牌问题生成标题策略…",
strategy: "正在结合优化关键词和竞品生成标题策略…",
strategyNoCompetitors: "正在结合优化关键词生成标题策略…",
finalize: "正在输出 5 个标题候选…",
},
outlineStages: {
context: "正在整理标题、品牌问题和竞品上下文…",
contextNoCompetitors: "正在整理标题、品牌问题和评测上下文…",
context: "正在整理标题、优化关键词和竞品上下文…",
contextNoCompetitors: "正在整理标题、优化关键词和评测上下文…",
structure: "正在根据已选结构生成文章大纲…",
finalize: "正在输出可编辑的大纲结果…",
},
},
messages: {
requiredField: "请先填写 {field}",
missingBrand: "请先选择品牌库对象或输入品牌名称",
selectBrandBeforeQuestion: "请先从品牌库选择品牌后再选择品牌问题",
missingPrimaryQuestion: "请选择一个品牌主问题",
missingBrand: "请先输入或选择公司名称",
selectBrandBeforeQuestion: "请先从公司名称下拉选择品牌库对象后再选择优化关键词",
missingPrimaryQuestion: "请选择一个优化关键词",
missingTitle: "请先选择或输入文章标题",
missingReviewIntroHook: "请先选择一个评测引言钩子",
missingOutline: "请至少选择一个文章结构段落",
missingGeneratedOutline: "请先生成并确认文章大纲",
customOutlineTooLong: "自定义结构不能超过 {count} 个字",
selectBrandBeforeCompetitors: "请先从品牌库选择品牌后再管理竞品",
selectBrandBeforeFavorite: "请先从品牌库选择品牌后再收藏竞品",
selectBrandBeforeCompetitors: "请先从公司名称下拉选择品牌库对象后再管理竞品",
selectBrandBeforeFavorite: "请先从公司名称下拉选择品牌库对象后再收藏竞品",
missingCompetitorName: "至少需要填写竞品名称后才能收藏",
savedCompetitor: "竞品已收藏到品牌词库",
unsavedCompetitor: "已取消收藏该竞品",
@@ -1213,20 +1213,20 @@ const zhCN = {
},
},
brands: {
eyebrow: "Brand Management",
title: "品牌管理",
description: "通过品牌名称和品牌相关信息维护问题集和竞品资产。",
eyebrow: "Company & Lexicon",
title: "公司和词库",
description: "通过品牌名称和品牌相关信息维护关键词库(搜索词)和竞品资产。",
railTitle: "品牌库",
selectBrand: "选择品牌后即可继续维护问题集。",
selectBrand: "选择品牌后即可继续维护关键词库(搜索词)。",
newBrand: "新建品牌",
editBrand: "编辑品牌",
deleteBrand: "删除品牌",
guide: {
title: "品牌管理说明",
body: "管理品牌、问题集和竞品库,用于后续 AI 平台监测与 GEO 分析。",
body: "管理品牌、关键词库(搜索词)和竞品库,用于后续 AI 平台监测与 GEO 分析。",
},
tabs: {
questions: "问题集",
questions: "关键词库(搜索词)",
competitors: "竞品库",
},
quota: {
@@ -1249,20 +1249,20 @@ const zhCN = {
},
sections: {
keywords: "关键词",
questions: "问题集",
questions: "关键词库(搜索词)",
competitors: "竞品库",
versions: "版本记录",
},
actions: {
addBrand: "添加品牌",
newKeyword: "新建关键词",
newQuestion: "新建问题",
newQuestion: "新建搜索词",
newCompetitor: "新建竞品",
viewVersions: "查看版本",
},
empty: {
brands: "还没有品牌数据,先创建一个品牌。",
questions: "当前品牌下暂无问题。",
questions: "当前品牌下暂无搜索词。",
competitors: "当前品牌下暂无竞品数据。",
brandDescription: "暂无品牌描述",
competitorDescription: "暂无竞品描述",
@@ -1272,7 +1272,7 @@ const zhCN = {
brandWebsite: "公司官网(选填)",
brandDescription: "品牌描述",
keywordName: "关键词名称",
questionText: "问题内容",
questionText: "搜索词内容",
competitorName: "竞品名称",
competitorWebsite: "竞品官网",
competitorDescription: "竞品描述",
@@ -1286,9 +1286,9 @@ const zhCN = {
createKeyword: "关键词已创建",
updateKeyword: "关键词已更新",
deleteKeyword: "关键词已删除",
createQuestion: "问题已创建",
updateQuestion: "问题已更新",
deleteQuestion: "问题已删除",
createQuestion: "搜索词已创建",
updateQuestion: "搜索词已更新",
deleteQuestion: "搜索词已删除",
createCompetitor: "竞品已创建",
updateCompetitor: "竞品已更新",
deleteCompetitor: "竞品已删除",
@@ -1296,21 +1296,21 @@ const zhCN = {
chooseKeyword: "请先选择关键词",
brandLimitReached: "当前套餐最多可绑定 {limit} 个品牌公司",
keywordLimitReached: "当前账号最多可绑定 {limit} 个关键词",
questionLimitReached: "当前账号最多可维护 {limit} 个问题",
questionLimitReached: "当前账号最多可维护 {limit} 个搜索词",
},
questions: {
create: "新建问题",
createSingle: "新增单条问题",
expand: "批量扩展问题",
usage: "问题 {used} / {total}",
create: "新建搜索词",
createSingle: "新增单条搜索词",
expand: "批量扩展搜索词",
usage: "搜索词 {used} / {total}",
remaining: "还可保存 {count} 条",
generate: "生成候选",
emptyCandidates: "还没有候选,先从上方生成或粘贴问题。",
emptyCandidates: "还没有候选,先从上方生成或粘贴搜索词。",
table: {
question: "问题集",
question: "关键词库(搜索词)",
},
modal: {
title: "新建问题",
title: "新建搜索词",
currentBrand: "当前品牌",
methods: {
single: "单条新增",
@@ -1320,17 +1320,17 @@ const zhCN = {
},
preview: "候选预览",
summary: "共 {total} 条候选 · 已选择 {selected} 条 · 过短 {tooShort} 条 · 重复 {duplicate} 条",
save: "保存问题 ({count})",
save: "保存搜索词 ({count})",
},
combination: {
region: "1.地域词",
prefix: "2.前缀词",
core: "3.核心词(必填)",
core: "3.精准词(必填)",
industry: "4.行业词(必填)",
suffix: "5.后缀词",
defaultWord: "默认词",
estimate: "预计生成 {count} 条候选",
coreRequired: "核心不能为空",
coreRequired: "精准词不能为空",
industryRequired: "行业不能为空",
},
aiFill: {
@@ -1341,24 +1341,24 @@ const zhCN = {
waitingStages: {
region: "正在识别所在地域并匹配城市、省会与大区…",
generate: "正在分析品牌和高频搜索意图…",
refine: "正在整理可组合的核心词组…",
refine: "正在整理前缀词、精准词、行业词和后缀词…",
},
},
ai: {
note: "AI 扩展会消耗 1 个 AI 点;生成失败时系统会自动退还。",
seedTopic: "扩展主题",
seedTopicPlaceholder: "输入主题后按回车,可添加多个",
seedTopicHint: "支持回车、逗号或分号分隔多个主题;系统会逐个扩展并自动合并去重。",
seedTopic: "用户搜索词",
seedTopicPlaceholder: "输入搜索词后按回车,可添加多个",
seedTopicHint: "按用户搜索词扩展强商业属性候选;公司名称是目标达成词,不是候选搜索词。",
generate: "AI 生成候选",
waitingTitle: "AI 正在生成候选问题,请稍后…",
waitingTitle: "AI 正在生成候选搜索词,请稍后…",
waitingStages: {
context: "正在整理品牌与主题上下文…",
generate: "正在生成高意图候选问题…",
refine: "正在去重并筛选可保存问题…",
context: "正在整理用户搜索词和目标达成词…",
generate: "正在生成适合推荐企业的强商业候选搜索词…",
refine: "正在去重并筛选可保存搜索词…",
},
},
batch: {
label: "每行一个问题",
label: "每行一个搜索词",
},
badges: {
duplicate: "重复",
@@ -1366,20 +1366,20 @@ const zhCN = {
},
messages: {
partialSaved: "已保存 {created} 条,跳过 {skipped} 条",
limitReached: "当前账号最多可维护 {limit} 个问题,所有品牌共用该额度",
selectionLimitReached: "当前套餐最多可维护 {limit} 个问题,已达到可选上限",
limitReached: "当前账号最多可维护 {limit} 个搜索词,所有品牌共用该额度",
selectionLimitReached: "当前套餐最多可维护 {limit} 个搜索词,已达到可选上限",
truncated: "候选超过上限,已截断为 500 条。",
cacheHit: "已使用最近一次 AI 扩展结果。",
aiCharged: "本次 AI 扩展已消耗 {count} 个 AI 点。",
},
errors: {
emptyInput: "请先输入问题内容",
emptyInput: "请先输入搜索词内容",
noSelection: "请至少选择 1 条候选",
noValidQuestions: "没有可保存的问题,请调整候选后重试",
noValidQuestions: "没有可保存的搜索词,请调整候选后重试",
},
page: {
title: "新建问题",
subtitle: "按向导生成候选,确认后一次性保存到当前品牌的问题集。",
title: "新建搜索词",
subtitle: "按向导生成候选,确认后一次性保存到当前品牌的关键词库(搜索词)。",
steps: {
configure: "配置生成方式",
preview: "确认候选",
@@ -1387,12 +1387,12 @@ const zhCN = {
methodTitle: "选择创建方式",
methodHint: "候选保存前都是临时内容,你可以先生成、再筛选和编辑。",
methods: {
combination: "按图示词列批量拓展问题候选。",
ai: "让 AI 围绕主题生成问题候选。",
batch: "从表格或文档中批量粘贴问题。",
combination: "按图示词列批量拓展搜索词候选。",
ai: "让 AI 围绕用户搜索词生成候选,不把公司名称写进候选。",
batch: "从表格或文档中批量粘贴搜索词。",
},
expansionHint: "每行一个词;空列会按默认词跳过,核心词和行业词必填。",
batchHint: "每行一个问题,保存前可以逐条编辑和勾选。",
expansionHint: "前缀词填商业修饰,如性价比高的、口碑好的;精准词填具体需求,如精装、100平;行业词填品类,如全屋定制、家具定制;后缀词填哪家好、怎么选。",
batchHint: "每行一个搜索词,保存前可以逐条编辑和勾选。",
},
},
},
+2
View File
@@ -3,6 +3,7 @@ import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import {
Alert,
App as AntApp,
AutoComplete,
Avatar,
Badge,
Button,
@@ -111,6 +112,7 @@ app.component('ATextarea', Input.TextArea)
;[
Alert,
AntApp,
AutoComplete,
Avatar,
Badge,
Button,
@@ -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