feat(questions): make brand questions the primary monitoring & template axis
- add /questions/combination-fill endpoint with AI-driven, IP-region-aware matrix fill - extract ip2region resolver from ops/app into shared/ipregion for cross-service use - thread question_id filter through dashboard composite, citation summary, and collect-now - switch template wizard from keyword inputs to brand-question selection (primary + supplemental) - pass brand_question and supplemental_questions through assist/title/outline prompts - add AiWaitingModal + 45s client timeout and request_timeout error mapping for long AI flows Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
open: boolean
|
||||
title: string
|
||||
progress: number
|
||||
stage?: string
|
||||
width?: string | number
|
||||
}>(),
|
||||
{
|
||||
stage: '',
|
||||
width: '640px',
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
:footer="null"
|
||||
:closable="false"
|
||||
:mask-closable="false"
|
||||
centered
|
||||
:width="width"
|
||||
wrap-class-name="ai-waiting-modal-wrap"
|
||||
>
|
||||
<div class="ai-waiting-modal">
|
||||
<div class="ai-waiting-modal__orb" />
|
||||
<h3>{{ title }}</h3>
|
||||
<a-progress :percent="progress" status="active" :show-info="false" />
|
||||
<p>{{ stage }}</p>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-waiting-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 24px 20px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ai-waiting-modal__orb {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.92), transparent 38%),
|
||||
conic-gradient(from 120deg, #4f8fff, #7dd3fc, #e879f9, #4f8fff);
|
||||
box-shadow:
|
||||
0 18px 38px rgba(79, 143, 255, 0.2),
|
||||
inset 0 0 18px rgba(255, 255, 255, 0.4);
|
||||
animation: ai-orb-float 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ai-waiting-modal h3 {
|
||||
margin: 0;
|
||||
color: #1b2741;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.ai-waiting-modal p {
|
||||
min-height: 22px;
|
||||
margin: 0;
|
||||
color: #5f6f8a;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@keyframes ai-orb-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px) scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ai-waiting-modal {
|
||||
gap: 20px;
|
||||
padding: 20px 12px 8px;
|
||||
}
|
||||
|
||||
.ai-waiting-modal h3 {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -151,6 +151,8 @@ 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_combination_fill: 'Expansion Tool AI Fill',
|
||||
},
|
||||
aiUsageStatus: {
|
||||
pending: 'Processing',
|
||||
@@ -243,9 +245,20 @@ const enUS = {
|
||||
aiPoints: {
|
||||
balance: 'Available Points',
|
||||
baseChars: 'Base Characters',
|
||||
usageTypeFallback: 'AI Service Call',
|
||||
ledgerTitle: 'Usage Ledger',
|
||||
ledgerLimit: 'Latest {limit} records',
|
||||
ledgerTotal: '{total} records',
|
||||
refundedHelp:
|
||||
'The system deducted points first. After the task failed, the points were returned automatically, so net usage is 0.',
|
||||
amount: {
|
||||
netZero: 'Net 0',
|
||||
zero: '0',
|
||||
pendingFlow: '{points} pts reserved',
|
||||
completedFlow: '{points} pts charged',
|
||||
refundedFlow: '{points} pts deducted, then returned',
|
||||
failedFlow: 'No charge',
|
||||
},
|
||||
table: {
|
||||
usageType: 'Usage',
|
||||
points: 'Points',
|
||||
@@ -431,10 +444,11 @@ const enUS = {
|
||||
},
|
||||
tracking: {
|
||||
brandPlaceholder: 'Select brand',
|
||||
keywordPlaceholder: 'Select keyword',
|
||||
keywordPlaceholder: 'Select question set',
|
||||
allQuestionSets: 'All question sets',
|
||||
collectNow: 'Collect Now',
|
||||
collectBrandRequired: 'Select a brand before collecting.',
|
||||
collectKeywordRequired: 'Select a keyword before collecting the current question set.',
|
||||
collectKeywordRequired: 'Select a question set 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:
|
||||
@@ -458,8 +472,9 @@ const enUS = {
|
||||
platformMatrixTitle: 'Platform Comparison Matrix',
|
||||
hotQuestions: 'Hot Questions',
|
||||
hotQuestionsTitle: 'Hot Questions',
|
||||
hotQuestionsHint: 'Questions come from the brand library under the selected keyword.',
|
||||
questionSearchPlaceholder: 'Search questions under the selected keyword',
|
||||
hotQuestionsHint:
|
||||
'Choose all question sets or one specific question. Selecting one question limits display and collection to that question.',
|
||||
questionSearchPlaceholder: 'Search questions in the selected scope',
|
||||
questionDetailEyebrow: 'Question Detail',
|
||||
questionDetailCollectedAt: 'Collected at {time}',
|
||||
questionDetailFallback: 'Question Detail',
|
||||
@@ -813,6 +828,9 @@ const enUS = {
|
||||
website: 'Official website',
|
||||
brandSummary: 'AI brand summary',
|
||||
keywords: 'Keywords',
|
||||
brandQuestions: 'Brand questions',
|
||||
primaryQuestion: 'Primary brand question',
|
||||
supplementalQuestions: 'Supplemental questions',
|
||||
competitors: 'Competitors',
|
||||
chooseTitle: 'Choose a title',
|
||||
customTitle: 'Custom title',
|
||||
@@ -829,11 +847,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. AI analysis will expand keywords, competitors, and title options.',
|
||||
'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.',
|
||||
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.',
|
||||
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.',
|
||||
competitors:
|
||||
'Competitors stay editable. You can add rows manually; saving to the competitor library is only available after choosing a library brand.',
|
||||
competitorEmptyEditable:
|
||||
@@ -891,6 +915,8 @@ const enUS = {
|
||||
brandName: 'Enter a brand name, or refine the selected library brand',
|
||||
website: 'Official website, optional',
|
||||
keywords: 'Type keywords and press Enter',
|
||||
primaryQuestion: 'Choose one primary brand question',
|
||||
supplementalQuestions: 'Optional, choose up to 3 supplemental questions',
|
||||
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',
|
||||
@@ -904,32 +930,33 @@ const enUS = {
|
||||
aiBanner: {
|
||||
title: 'One-click AI analysis',
|
||||
defaultDescription:
|
||||
'AI will complete the brand summary, find core keywords, and extract competitor context.',
|
||||
'AI will use the brand question to complete the brand summary and extract competitor context.',
|
||||
productReviewDescription:
|
||||
'AI will organize the product context, extract review keywords, and suggest comparable alternatives.',
|
||||
'AI will use the brand question to organize the product context and suggest comparable alternatives.',
|
||||
researchReportDescription:
|
||||
'AI will organize the topic context, extract research keywords, and suggest reference targets.',
|
||||
'AI will use the brand question to organize the topic context and suggest reference targets.',
|
||||
},
|
||||
assist: {
|
||||
analyzeTitle: 'AI is analyzing keywords and competitors. Please wait…',
|
||||
analyzeTitleNoCompetitors: 'AI is analyzing keywords and context. Please wait…',
|
||||
analyzeTitle: 'AI is analyzing brand questions and competitors. Please wait…',
|
||||
analyzeTitleNoCompetitors: 'AI is analyzing brand questions and context. Please wait…',
|
||||
titleTitle: 'AI is generating titles. Please wait…',
|
||||
outlineTitle: 'AI is generating the article outline. Please wait…',
|
||||
analyzeStages: {
|
||||
brand: 'Analyzing the brand context…',
|
||||
keywords: 'Extracting keyword opportunities…',
|
||||
questions: 'Organizing brand questions…',
|
||||
competitors: 'Mapping competitors and websites…',
|
||||
context: 'Organizing the content context…',
|
||||
},
|
||||
titleStages: {
|
||||
context: 'Preparing the current brief…',
|
||||
strategy: 'Generating title directions from keywords and competitors…',
|
||||
strategyNoCompetitors: 'Generating title directions from the keywords and brief…',
|
||||
strategy: 'Generating title directions from brand questions and competitors…',
|
||||
strategyNoCompetitors: 'Generating title directions from the brand question and brief…',
|
||||
finalize: 'Returning 5 title options…',
|
||||
},
|
||||
outlineStages: {
|
||||
context: 'Preparing the title, keywords, and competitor context…',
|
||||
contextNoCompetitors: 'Preparing the title, keywords, and content context…',
|
||||
context: 'Preparing the title, brand questions, and competitor context…',
|
||||
contextNoCompetitors: 'Preparing the title, brand questions, and content context…',
|
||||
structure: 'Generating the article outline from the selected structure…',
|
||||
finalize: 'Returning the editable outline result…',
|
||||
},
|
||||
@@ -937,6 +964,8 @@ 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.',
|
||||
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.',
|
||||
@@ -1355,10 +1384,27 @@ const enUS = {
|
||||
coreRequired: 'Core is required',
|
||||
industryRequired: 'Industry is required',
|
||||
},
|
||||
aiFill: {
|
||||
button: 'AI fill',
|
||||
success: 'Word columns filled. You can keep editing.',
|
||||
charged: 'Word columns filled. This used {count} AI point.',
|
||||
waitingTitle: 'AI is filling the expansion tool. Please wait...',
|
||||
waitingStages: {
|
||||
region: 'Detecting city, province capital, and macro region...',
|
||||
generate: 'Analyzing brand context and high-frequency intent...',
|
||||
refine: 'Refining short combinable keyword groups...',
|
||||
},
|
||||
},
|
||||
ai: {
|
||||
note: 'AI expansion consumes 1 AI point. Failed generations are refunded automatically.',
|
||||
seedTopic: 'Seed topic',
|
||||
generate: 'Generate with AI',
|
||||
waitingTitle: 'AI is generating candidate questions. Please wait...',
|
||||
waitingStages: {
|
||||
context: 'Organizing brand and topic context...',
|
||||
generate: 'Generating high-intent candidate questions...',
|
||||
refine: 'Deduplicating and filtering savable questions...',
|
||||
},
|
||||
},
|
||||
batch: {
|
||||
label: 'One question per line',
|
||||
@@ -1370,6 +1416,7 @@ 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.',
|
||||
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.',
|
||||
@@ -1378,8 +1425,6 @@ const enUS = {
|
||||
emptyInput: 'Enter question content first.',
|
||||
noSelection: 'Select at least one candidate.',
|
||||
noValidQuestions: 'No valid questions can be saved. Adjust candidates and try again.',
|
||||
invalidCandidate:
|
||||
'This candidate cannot be selected until it is no longer too short, duplicated, or invalid.',
|
||||
},
|
||||
page: {
|
||||
title: 'New question',
|
||||
|
||||
@@ -147,6 +147,8 @@ const zhCN = {
|
||||
template_outline_generate: "AI 生成大纲",
|
||||
kol_prompt_generate: "KOL 模版生成",
|
||||
kol_prompt_optimize: "KOL 模版优化",
|
||||
question_distill: "AI 生成候选问题",
|
||||
question_combination_fill: "拓词工具 AI 填词",
|
||||
},
|
||||
aiUsageStatus: {
|
||||
pending: "处理中",
|
||||
@@ -232,9 +234,19 @@ const zhCN = {
|
||||
aiPoints: {
|
||||
balance: "可用点数",
|
||||
baseChars: "基准字数",
|
||||
usageTypeFallback: "AI 服务调用",
|
||||
ledgerTitle: "消耗流水",
|
||||
ledgerLimit: "最近 {limit} 条记录",
|
||||
ledgerTotal: "共 {total} 条记录",
|
||||
refundedHelp: "系统已先扣除点数;任务失败后自动退回,本次净消耗为 0。",
|
||||
amount: {
|
||||
netZero: "净消耗 0",
|
||||
zero: "0",
|
||||
pendingFlow: "已预扣 {points} 点",
|
||||
completedFlow: "已扣除 {points} 点",
|
||||
refundedFlow: "已扣除 {points} 点后退回",
|
||||
failedFlow: "未产生扣费",
|
||||
},
|
||||
table: {
|
||||
usageType: "使用场景",
|
||||
points: "点数",
|
||||
@@ -414,10 +426,11 @@ const zhCN = {
|
||||
},
|
||||
tracking: {
|
||||
brandPlaceholder: "选择品牌",
|
||||
keywordPlaceholder: "选择关键词",
|
||||
keywordPlaceholder: "选择问题集",
|
||||
allQuestionSets: "全部问题集",
|
||||
collectNow: "立即采集",
|
||||
collectBrandRequired: "请先选择品牌,再发起采集",
|
||||
collectKeywordRequired: "请先选择关键词,再立即采集当前问题",
|
||||
collectKeywordRequired: "请选择问题集后再发起采集",
|
||||
collectTodayOnly: "立即采集只支持今天的数据,请切回今天后再操作",
|
||||
collectClientChecking: "正在检查当前账号的桌面客户端状态",
|
||||
collectClientOnlineRequired: "当前登录账号的桌面客户端未在线,暂时不能立即采集",
|
||||
@@ -437,8 +450,8 @@ const zhCN = {
|
||||
platformMatrixTitle: "平台对比矩阵",
|
||||
hotQuestions: "Hot Questions",
|
||||
hotQuestionsTitle: "高频问题",
|
||||
hotQuestionsHint: "高频问题来自品牌库当前关键词下的全部问题。",
|
||||
questionSearchPlaceholder: "搜索当前关键词下的问题",
|
||||
hotQuestionsHint: "可选择全部问题集或具体单个问题;选择单个问题时只展示并采集该问题。",
|
||||
questionSearchPlaceholder: "搜索当前范围下的问题",
|
||||
questionDetailEyebrow: "Question Detail",
|
||||
questionDetailCollectedAt: "采集时间 {time}",
|
||||
questionDetailFallback: "问题详情",
|
||||
@@ -781,6 +794,9 @@ const zhCN = {
|
||||
website: "官网地址",
|
||||
brandSummary: "AI 品牌摘要",
|
||||
keywords: "关键词",
|
||||
brandQuestions: "品牌问题",
|
||||
primaryQuestion: "品牌主问题",
|
||||
supplementalQuestions: "补充覆盖问题",
|
||||
competitors: "竞品列表",
|
||||
chooseTitle: "选择文章标题",
|
||||
customTitle: "自定义标题",
|
||||
@@ -797,9 +813,13 @@ const zhCN = {
|
||||
},
|
||||
hints: {
|
||||
brandContext: "可选。选择后会自动带出关键词与竞品信息。",
|
||||
brandFlow: "品牌可直接输入,也可以从品牌库带入。点击分析后,AI 会补充关键词、竞品和标题建议。",
|
||||
brandFlow: "品牌可直接输入,也可以从品牌库带入。选择品牌问题后,AI 会补充品牌摘要、竞品和标题建议。",
|
||||
brandSummaryPlaceholder: "AI 分析后会在这里补充品牌简介、差异点和适合切入的内容角度。",
|
||||
keywords: "支持直接输入,也支持复用品牌词库里的关键词。AI 分析结果会自动补充到这里。",
|
||||
brandQuestions: "主问题决定文章主线,补充覆盖问题只用于扩展相关搜索意图。",
|
||||
supplementalQuestions: "最多选择 3 个,用于覆盖相邻搜索问题,避免文章主题发散。",
|
||||
questionEmpty: "当前品牌还没有可选问题",
|
||||
selectBrandFirst: "请先选择品牌库对象",
|
||||
competitors: "竞品支持手动编辑、追加;只有从品牌库选择品牌后,才可以收藏到品牌词库。AI 分析会尽量补齐候选竞品。",
|
||||
competitorEmptyEditable: "还没有竞品,先手动添加一条或点击分析让 AI 自动补齐。",
|
||||
templateFields: "这里保留当前模板自己的补充字段,用来约束 AI 生成角度。",
|
||||
@@ -852,6 +872,8 @@ const zhCN = {
|
||||
brandName: "输入品牌名,或在选择品牌后继续微调",
|
||||
website: "输入官网地址,选填",
|
||||
keywords: "输入关键词后回车,可多选",
|
||||
primaryQuestion: "选择一个品牌主问题",
|
||||
supplementalQuestions: "可选,最多选择 3 个补充问题",
|
||||
customTitle: "如需覆盖 AI 标题,可在这里直接输入",
|
||||
customOutline: "补充一个额外结构,例如“选购建议”",
|
||||
keyPoints: "告诉 AI 还需要强调哪些要点、风格或限制条件",
|
||||
@@ -864,30 +886,31 @@ const zhCN = {
|
||||
promptSealed: "Prompt 已封装",
|
||||
aiBanner: {
|
||||
title: "一键智能分析",
|
||||
defaultDescription: "AI 将自动补充品牌简介、发掘核心关键词并智能提取竞品信息",
|
||||
productReviewDescription: "AI 将自动梳理产品背景、提炼评测关键词并生成同类替代方案参考",
|
||||
researchReportDescription: "AI 将自动梳理主题背景、提炼研究关键词并生成参照对象参考",
|
||||
defaultDescription: "AI 将基于品牌问题补充品牌简介,并智能提取竞品信息",
|
||||
productReviewDescription: "AI 将基于品牌问题梳理产品背景,并生成同类替代方案参考",
|
||||
researchReportDescription: "AI 将基于品牌问题梳理主题背景,并生成参照对象参考",
|
||||
},
|
||||
assist: {
|
||||
analyzeTitle: "AI 正在分析关键词和竞品,请稍后…",
|
||||
analyzeTitleNoCompetitors: "AI 正在分析关键词和评测上下文,请稍后…",
|
||||
analyzeTitle: "AI 正在分析品牌问题和竞品,请稍后…",
|
||||
analyzeTitleNoCompetitors: "AI 正在分析品牌问题和评测上下文,请稍后…",
|
||||
titleTitle: "AI 正在生成标题,请稍后…",
|
||||
outlineTitle: "AI 正在生成文章大纲,请稍后…",
|
||||
analyzeStages: {
|
||||
brand: "品牌信息分析中…",
|
||||
keywords: "关键词提炼中…",
|
||||
questions: "品牌问题整理中…",
|
||||
competitors: "竞品与官网信息补充中…",
|
||||
context: "评测上下文整理中…",
|
||||
},
|
||||
titleStages: {
|
||||
context: "正在整理当前内容上下文…",
|
||||
strategy: "正在结合关键词和竞品生成标题策略…",
|
||||
strategyNoCompetitors: "正在结合关键词生成标题策略…",
|
||||
strategy: "正在结合品牌问题和竞品生成标题策略…",
|
||||
strategyNoCompetitors: "正在结合品牌问题生成标题策略…",
|
||||
finalize: "正在输出 5 个标题候选…",
|
||||
},
|
||||
outlineStages: {
|
||||
context: "正在整理标题、关键词和竞品上下文…",
|
||||
contextNoCompetitors: "正在整理标题、关键词和评测上下文…",
|
||||
context: "正在整理标题、品牌问题和竞品上下文…",
|
||||
contextNoCompetitors: "正在整理标题、品牌问题和评测上下文…",
|
||||
structure: "正在根据已选结构生成文章大纲…",
|
||||
finalize: "正在输出可编辑的大纲结果…",
|
||||
},
|
||||
@@ -895,6 +918,8 @@ const zhCN = {
|
||||
messages: {
|
||||
requiredField: "请先填写 {field}",
|
||||
missingBrand: "请先选择品牌库对象或输入品牌名称",
|
||||
selectBrandBeforeQuestion: "请先从品牌库选择品牌后再选择品牌问题",
|
||||
missingPrimaryQuestion: "请选择一个品牌主问题",
|
||||
missingTitle: "请先选择或输入文章标题",
|
||||
missingReviewIntroHook: "请先选择一个评测引言钩子",
|
||||
missingOutline: "请至少选择一个文章结构段落",
|
||||
@@ -1287,10 +1312,27 @@ const zhCN = {
|
||||
coreRequired: "核心不能为空",
|
||||
industryRequired: "行业不能为空",
|
||||
},
|
||||
aiFill: {
|
||||
button: "AI 填词",
|
||||
success: "已填入拓词词组,可继续编辑。",
|
||||
charged: "已填入拓词词组,本次消耗 {count} 个 AI 点。",
|
||||
waitingTitle: "AI 正在填充拓词工具,请稍后…",
|
||||
waitingStages: {
|
||||
region: "正在识别所在地域并匹配城市、省会与大区…",
|
||||
generate: "正在分析品牌和高频搜索意图…",
|
||||
refine: "正在整理可组合的核心词组…",
|
||||
},
|
||||
},
|
||||
ai: {
|
||||
note: "AI 扩展会消耗 1 个 AI 点;生成失败时系统会自动退还。",
|
||||
seedTopic: "扩展主题",
|
||||
generate: "AI 生成候选",
|
||||
waitingTitle: "AI 正在生成候选问题,请稍后…",
|
||||
waitingStages: {
|
||||
context: "正在整理品牌与主题上下文…",
|
||||
generate: "正在生成高意图候选问题…",
|
||||
refine: "正在去重并筛选可保存问题…",
|
||||
},
|
||||
},
|
||||
batch: {
|
||||
label: "每行一个问题",
|
||||
@@ -1302,6 +1344,7 @@ const zhCN = {
|
||||
messages: {
|
||||
partialSaved: "已保存 {created} 条,跳过 {skipped} 条",
|
||||
limitReached: "当前账号最多可维护 {limit} 个问题,所有品牌共用该额度",
|
||||
selectionLimitReached: "当前套餐最多可维护 {limit} 个问题,已达到可选上限",
|
||||
truncated: "候选超过上限,已截断为 500 条。",
|
||||
cacheHit: "已使用最近一次 AI 扩展结果。",
|
||||
aiCharged: "本次 AI 扩展已消耗 {count} 个 AI 点。",
|
||||
@@ -1310,7 +1353,6 @@ const zhCN = {
|
||||
emptyInput: "请先输入问题内容",
|
||||
noSelection: "请至少选择 1 条候选",
|
||||
noValidQuestions: "没有可保存的问题,请调整候选后重试",
|
||||
invalidCandidate: "该候选暂不可选择,请先处理过短、重复或非问题文本",
|
||||
},
|
||||
page: {
|
||||
title: "新建问题",
|
||||
|
||||
@@ -92,6 +92,8 @@ import type {
|
||||
PublishRecord,
|
||||
Question,
|
||||
QuestionCandidateResult,
|
||||
QuestionCombinationFillRequest,
|
||||
QuestionCombinationFillResult,
|
||||
QuestionCombinationRequest,
|
||||
QuestionDistillRequest,
|
||||
QuestionRequest,
|
||||
@@ -150,6 +152,7 @@ const rawBaseURL = import.meta.env.VITE_API_BASE_URL ?? ''
|
||||
const baseURL = normalizeApiBaseURL(rawBaseURL)
|
||||
const generationStreamEnabled = import.meta.env.VITE_GENERATION_STREAM_ENABLED === 'true'
|
||||
const accessRefreshSkewMs = 2 * 60 * 1000
|
||||
const aiDistillRequestTimeoutMs = 45_000
|
||||
|
||||
const publicClient = createApiClient({ baseURL })
|
||||
let storedRefreshPromise: Promise<AuthTokens> | null = null
|
||||
@@ -1049,10 +1052,18 @@ export const brandsApi = {
|
||||
payload,
|
||||
)
|
||||
},
|
||||
fillQuestionCombination(brandId: number, payload: QuestionCombinationFillRequest) {
|
||||
return apiClient.post<QuestionCombinationFillResult, QuestionCombinationFillRequest>(
|
||||
`/api/tenant/brands/${brandId}/questions/combination-fill`,
|
||||
payload,
|
||||
{ timeout: aiDistillRequestTimeoutMs },
|
||||
)
|
||||
},
|
||||
distillQuestions(brandId: number, payload: QuestionDistillRequest) {
|
||||
return apiClient.post<QuestionCandidateResult, QuestionDistillRequest>(
|
||||
`/api/tenant/brands/${brandId}/questions/ai-distill`,
|
||||
payload,
|
||||
{ timeout: aiDistillRequestTimeoutMs },
|
||||
)
|
||||
},
|
||||
classifyQuestions(brandId: number, payload: ClassifyQuestionsRequest) {
|
||||
@@ -1219,6 +1230,7 @@ export const monitoringApi = {
|
||||
dashboardComposite(params: {
|
||||
brand_id?: number
|
||||
keyword_id?: number | null
|
||||
question_id?: number | null
|
||||
days?: number
|
||||
business_date?: string
|
||||
ai_platform_id?: string
|
||||
@@ -1234,6 +1246,7 @@ export const monitoringApi = {
|
||||
days?: number
|
||||
brand_id?: number
|
||||
keyword_id?: number | null
|
||||
question_id?: number | null
|
||||
business_date?: string
|
||||
ai_platform_id?: string
|
||||
}) {
|
||||
@@ -1259,10 +1272,13 @@ export const monitoringApi = {
|
||||
{ params },
|
||||
)
|
||||
},
|
||||
collectNow(brandId: number, payload?: { keyword_id?: number | null; platform_ids?: string[] }) {
|
||||
collectNow(
|
||||
brandId: number,
|
||||
payload?: { keyword_id?: number | null; question_id?: number | null; platform_ids?: string[] },
|
||||
) {
|
||||
return apiClient.post<
|
||||
MonitoringCollectNowResponse,
|
||||
{ keyword_id?: number | null; platform_ids?: string[] }
|
||||
{ keyword_id?: number | null; question_id?: number | null; platform_ids?: string[] }
|
||||
>(`/api/tenant/monitoring/brands/${brandId}/collect-now`, payload ?? {})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ const errorMessageMap: Record<string, string> = {
|
||||
publisher_plugin_unknown_error: '浏览器插件调用失败,请稍后重试',
|
||||
installation_offline: '桌面客户端未在线,请打开桌面客户端后重试',
|
||||
network_error: '网络连接失败,请稍后重试',
|
||||
request_timeout: '请求超时,AI 生成耗时较长,请稍后重试或稍候再查看结果',
|
||||
unknown_error: '发生未知错误',
|
||||
}
|
||||
|
||||
@@ -136,13 +137,27 @@ export function isHandledAuthError(error: unknown): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
const TIMEOUT_MESSAGE_PATTERN = /^timeout of \d+ms exceeded$/i
|
||||
|
||||
function translateRawErrorMessage(raw: string): string | null {
|
||||
const mapped = errorMessageMap[raw]
|
||||
if (mapped) {
|
||||
return mapped
|
||||
}
|
||||
if (TIMEOUT_MESSAGE_PATTERN.test(raw)) {
|
||||
return errorMessageMap.request_timeout
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function formatError(error: unknown): string {
|
||||
if (isHandledAuthError(error)) {
|
||||
return '登录已过期,请重新登录'
|
||||
}
|
||||
|
||||
if (error instanceof ApiClientError) {
|
||||
const message = errorMessageMap[error.message] ?? error.message.replaceAll('_', ' ')
|
||||
const translated = translateRawErrorMessage(error.message)
|
||||
const message = translated ?? error.message.replaceAll('_', ' ')
|
||||
if (isAiPointsInsufficient(error)) {
|
||||
return message
|
||||
}
|
||||
@@ -150,7 +165,7 @@ export function formatError(error: unknown): string {
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return errorMessageMap[error.message] ?? error.message
|
||||
return translateRawErrorMessage(error.message) ?? error.message
|
||||
}
|
||||
|
||||
return '发生未知错误'
|
||||
|
||||
@@ -53,7 +53,7 @@ const columns = computed<TableColumnsType<AIPointUsageLog>>(() => [
|
||||
title: t('aiPoints.table.points'),
|
||||
dataIndex: 'points',
|
||||
key: 'points',
|
||||
width: 110,
|
||||
width: 188,
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
@@ -89,7 +89,7 @@ function handleTableChange(nextPage: number, nextPageSize: number): void {
|
||||
function getAIPointUsageTypeLabel(usageType: string): string {
|
||||
const key = `shell.aiUsageTypes.${usageType}`
|
||||
const label = t(key)
|
||||
return label === key ? usageType : label
|
||||
return label === key ? t('aiPoints.usageTypeFallback') : label
|
||||
}
|
||||
|
||||
function getAIPointStatusMeta(status: string): { label: string; color: string } {
|
||||
@@ -108,8 +108,48 @@ function getAIPointStatusMeta(status: string): { label: string; color: string }
|
||||
}
|
||||
}
|
||||
|
||||
function formatAIPointDelta(item: AIPointUsageLog): string {
|
||||
return item.status === 'refunded' ? `+${item.points}` : `-${item.points}`
|
||||
function getAIPointStatusHelp(status: string): string | null {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return t('shell.aiUsagePendingHelp')
|
||||
case 'refunded':
|
||||
return t('aiPoints.refundedHelp')
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getAIPointAmountMeta(item: AIPointUsageLog): {
|
||||
primary: string
|
||||
detail: string
|
||||
tone: 'charge' | 'refund' | 'neutral'
|
||||
} {
|
||||
switch (item.status) {
|
||||
case 'refunded':
|
||||
return {
|
||||
primary: t('aiPoints.amount.netZero'),
|
||||
detail: t('aiPoints.amount.refundedFlow', { points: item.points }),
|
||||
tone: 'refund',
|
||||
}
|
||||
case 'pending':
|
||||
return {
|
||||
primary: `-${item.points}`,
|
||||
detail: t('aiPoints.amount.pendingFlow', { points: item.points }),
|
||||
tone: 'charge',
|
||||
}
|
||||
case 'completed':
|
||||
return {
|
||||
primary: `-${item.points}`,
|
||||
detail: t('aiPoints.amount.completedFlow', { points: item.points }),
|
||||
tone: 'charge',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
primary: t('aiPoints.amount.zero'),
|
||||
detail: t('aiPoints.amount.failedFlow'),
|
||||
tone: 'neutral',
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -208,12 +248,13 @@ function formatAIPointDelta(item: AIPointUsageLog): string {
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'points'">
|
||||
<span
|
||||
class="points-delta"
|
||||
:class="{ 'points-delta--refund': record.status === 'refunded' }"
|
||||
<div
|
||||
class="points-cell"
|
||||
:class="`points-cell--${getAIPointAmountMeta(record).tone}`"
|
||||
>
|
||||
{{ formatAIPointDelta(record) }}
|
||||
</span>
|
||||
<strong>{{ getAIPointAmountMeta(record).primary }}</strong>
|
||||
<span>{{ getAIPointAmountMeta(record).detail }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'request_chars'">
|
||||
@@ -229,7 +270,10 @@ function formatAIPointDelta(item: AIPointUsageLog): string {
|
||||
:bordered="false"
|
||||
>
|
||||
{{ getAIPointStatusMeta(record.status).label }}
|
||||
<a-tooltip v-if="record.status === 'pending'" :title="t('shell.aiUsagePendingHelp')">
|
||||
<a-tooltip
|
||||
v-if="getAIPointStatusHelp(record.status)"
|
||||
:title="getAIPointStatusHelp(record.status)"
|
||||
>
|
||||
<QuestionCircleOutlined class="status-help-icon" />
|
||||
</a-tooltip>
|
||||
</a-tag>
|
||||
@@ -397,15 +441,37 @@ function formatAIPointDelta(item: AIPointUsageLog): string {
|
||||
color: #cf1322 !important;
|
||||
}
|
||||
|
||||
.points-delta {
|
||||
.points-cell {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
justify-items: end;
|
||||
min-width: 132px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.points-cell strong {
|
||||
color: #cf1322;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.points-delta--refund {
|
||||
.points-cell span {
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.points-cell--refund strong {
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.points-cell--refund span {
|
||||
color: #389e0d;
|
||||
}
|
||||
|
||||
.points-cell--neutral strong {
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
BulbOutlined,
|
||||
LeftOutlined,
|
||||
RobotOutlined,
|
||||
SaveOutlined,
|
||||
@@ -15,10 +16,11 @@ import type {
|
||||
} from '@geo/shared-types'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AiWaitingModal from '@/components/AiWaitingModal.vue'
|
||||
import { brandsApi } from '@/lib/api'
|
||||
import { formatError } from '@/lib/errors'
|
||||
|
||||
@@ -39,10 +41,18 @@ const createMode = ref<CreateMode>('combination')
|
||||
const candidateRows = ref<CandidateRow[]>([])
|
||||
const editingCandidateId = ref<string | null>(null)
|
||||
const pageNotice = ref('')
|
||||
const aiCandidateModalOpen = ref(false)
|
||||
const aiCandidateProgress = ref(12)
|
||||
const aiCandidateStageIndex = ref(0)
|
||||
const aiWaitingKind = ref<'candidate' | 'fill'>('candidate')
|
||||
|
||||
let aiCandidateProgressTimer: number | null = null
|
||||
let aiCandidateCloseTimer: number | null = null
|
||||
|
||||
const vFocus = {
|
||||
mounted(el: HTMLElement) {
|
||||
const input = el.tagName === 'INPUT' ? el : el.querySelector('input')
|
||||
const input =
|
||||
el instanceof HTMLInputElement ? el : el.querySelector<HTMLInputElement>('input')
|
||||
if (input) {
|
||||
input.focus()
|
||||
const len = input.value.length
|
||||
@@ -52,11 +62,11 @@ const vFocus = {
|
||||
}
|
||||
|
||||
const expansionForm = reactive({
|
||||
region: ['合肥', '上海', '华东', ''],
|
||||
prefix: ['好用的', '专业的', '靠谱的', ''],
|
||||
core: ['GEO', 'AI搜索优化', ''],
|
||||
industry: ['工具', '平台', '服务商', ''],
|
||||
suffix: ['哪家好', '怎么选', '推荐', ''],
|
||||
region: [''],
|
||||
prefix: [''],
|
||||
core: [''],
|
||||
industry: [''],
|
||||
suffix: [''],
|
||||
})
|
||||
|
||||
const aiForm = reactive({
|
||||
@@ -112,6 +122,20 @@ const remainingQuestions = computed(() => {
|
||||
}
|
||||
return Math.max(maxQuestions.value - usedQuestions.value, 0)
|
||||
})
|
||||
const selectedQuestionCount = computed(() => selectedCandidates.value.length)
|
||||
const displayUsedQuestions = computed(() => usedQuestions.value + selectedQuestionCount.value)
|
||||
const displayRemainingQuestions = computed(() => {
|
||||
if (!maxQuestions.value) {
|
||||
return 0
|
||||
}
|
||||
return Math.max(maxQuestions.value - displayUsedQuestions.value, 0)
|
||||
})
|
||||
const canSelectMoreCandidates = computed(() => {
|
||||
if (!maxQuestions.value) {
|
||||
return true
|
||||
}
|
||||
return displayUsedQuestions.value < maxQuestions.value
|
||||
})
|
||||
|
||||
const questionLimitReached = computed(() => {
|
||||
if (!maxQuestions.value) {
|
||||
@@ -164,6 +188,24 @@ const createModeSource = computed<QuestionSource>(() => {
|
||||
return 'combination'
|
||||
})
|
||||
|
||||
const aiCandidateStages = computed(() => [
|
||||
aiWaitingKind.value === 'fill'
|
||||
? t('brands.questions.aiFill.waitingStages.region')
|
||||
: t('brands.questions.ai.waitingStages.context'),
|
||||
aiWaitingKind.value === 'fill'
|
||||
? t('brands.questions.aiFill.waitingStages.generate')
|
||||
: t('brands.questions.ai.waitingStages.generate'),
|
||||
aiWaitingKind.value === 'fill'
|
||||
? t('brands.questions.aiFill.waitingStages.refine')
|
||||
: t('brands.questions.ai.waitingStages.refine'),
|
||||
])
|
||||
|
||||
const aiWaitingTitle = computed(() =>
|
||||
aiWaitingKind.value === 'fill'
|
||||
? t('brands.questions.aiFill.waitingTitle')
|
||||
: t('brands.questions.ai.waitingTitle'),
|
||||
)
|
||||
|
||||
const isPageLoading = computed(
|
||||
() =>
|
||||
brandQuery.isPending.value ||
|
||||
@@ -172,7 +214,10 @@ const isPageLoading = computed(
|
||||
)
|
||||
|
||||
const isGenerating = computed(
|
||||
() => previewMutations.combination.isPending.value || previewMutations.ai.isPending.value,
|
||||
() =>
|
||||
previewMutations.combination.isPending.value ||
|
||||
previewMutations.ai.isPending.value ||
|
||||
combinationFillMutation.isPending.value,
|
||||
)
|
||||
|
||||
const previewMutations = {
|
||||
@@ -219,6 +264,33 @@ const previewMutations = {
|
||||
}),
|
||||
}
|
||||
|
||||
const combinationFillMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
brandsApi.fillQuestionCombination(brandId.value, {
|
||||
region: splitLines(expansionForm.region),
|
||||
prefix: splitLines(expansionForm.prefix),
|
||||
core: splitLines(expansionForm.core),
|
||||
industry: splitLines(expansionForm.industry),
|
||||
suffix: splitLines(expansionForm.suffix),
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
fillExpansionColumn('region', result.region)
|
||||
fillExpansionColumn('prefix', result.prefix)
|
||||
fillExpansionColumn('core', result.core)
|
||||
fillExpansionColumn('industry', result.industry)
|
||||
fillExpansionColumn('suffix', result.suffix)
|
||||
pageNotice.value = ''
|
||||
message.success(
|
||||
result.ai_points_charged
|
||||
? t('brands.questions.aiFill.charged', { count: result.ai_points_charged })
|
||||
: t('brands.questions.aiFill.success'),
|
||||
)
|
||||
},
|
||||
onError: (error) => {
|
||||
pageNotice.value = formatError(error)
|
||||
},
|
||||
})
|
||||
|
||||
const materializeMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
brandsApi.materializeQuestions(brandId.value, {
|
||||
@@ -324,6 +396,11 @@ function removeItem(column: ColumnKey, index: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function fillExpansionColumn(column: ColumnKey, values: string[]): void {
|
||||
const cleaned = splitLines(values).slice(0, 4)
|
||||
expansionForm[column].splice(0, expansionForm[column].length, ...cleaned, '')
|
||||
}
|
||||
|
||||
function combinationPartCount(value: string | string[]): number {
|
||||
const count = splitLines(value).length
|
||||
return count > 0 ? count : 1
|
||||
@@ -345,7 +422,7 @@ function toCandidateRows(candidates: QuestionCandidate[]): CandidateRow[] {
|
||||
return candidates.map((candidate, index) => ({
|
||||
...candidate,
|
||||
id: `${Date.now()}-${index}-${candidate.text}`,
|
||||
selected: !candidate.suggest_skip,
|
||||
selected: false,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -372,7 +449,7 @@ function buildLocalCandidates(texts: string[], source: QuestionSource): Candidat
|
||||
too_short: tooShort,
|
||||
duplicate,
|
||||
suggest_skip: tooShort || duplicate || invalid,
|
||||
selected: !(tooShort || duplicate || invalid),
|
||||
selected: false,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
@@ -388,6 +465,62 @@ function changeCreateMode(mode: CreateMode): void {
|
||||
pageNotice.value = ''
|
||||
}
|
||||
|
||||
function clearAiCandidateProgressTimer(): void {
|
||||
if (aiCandidateProgressTimer === null) {
|
||||
return
|
||||
}
|
||||
window.clearInterval(aiCandidateProgressTimer)
|
||||
aiCandidateProgressTimer = null
|
||||
}
|
||||
|
||||
function clearAiCandidateCloseTimer(): void {
|
||||
if (aiCandidateCloseTimer === null) {
|
||||
return
|
||||
}
|
||||
window.clearTimeout(aiCandidateCloseTimer)
|
||||
aiCandidateCloseTimer = null
|
||||
}
|
||||
|
||||
function beginAiCandidateTask(kind: 'candidate' | 'fill' = 'candidate'): void {
|
||||
clearAiCandidateProgressTimer()
|
||||
clearAiCandidateCloseTimer()
|
||||
aiWaitingKind.value = kind
|
||||
aiCandidateModalOpen.value = true
|
||||
aiCandidateProgress.value = 12
|
||||
aiCandidateStageIndex.value = 0
|
||||
|
||||
aiCandidateProgressTimer = window.setInterval(() => {
|
||||
const nextProgress = Math.min(92, aiCandidateProgress.value + 8)
|
||||
aiCandidateProgress.value = nextProgress
|
||||
aiCandidateStageIndex.value = nextProgress >= 66 ? 2 : nextProgress >= 38 ? 1 : 0
|
||||
}, 700)
|
||||
}
|
||||
|
||||
async function fillCombinationWords(): Promise<void> {
|
||||
pageNotice.value = ''
|
||||
beginAiCandidateTask('fill')
|
||||
try {
|
||||
await combinationFillMutation.mutateAsync()
|
||||
} finally {
|
||||
finishAiCandidateTask()
|
||||
}
|
||||
}
|
||||
|
||||
function finishAiCandidateTask(): void {
|
||||
clearAiCandidateProgressTimer()
|
||||
aiCandidateProgress.value = 100
|
||||
aiCandidateStageIndex.value = aiCandidateStages.value.length - 1
|
||||
aiCandidateCloseTimer = window.setTimeout(() => {
|
||||
aiCandidateModalOpen.value = false
|
||||
aiCandidateCloseTimer = null
|
||||
}, 180)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearAiCandidateProgressTimer()
|
||||
clearAiCandidateCloseTimer()
|
||||
})
|
||||
|
||||
async function generateCandidates(): Promise<void> {
|
||||
pageNotice.value = ''
|
||||
if (questionLimitReached.value) {
|
||||
@@ -423,7 +556,12 @@ async function generateCandidates(): Promise<void> {
|
||||
pageNotice.value = t('brands.questions.errors.emptyInput')
|
||||
return
|
||||
}
|
||||
await previewMutations.ai.mutateAsync()
|
||||
beginAiCandidateTask('candidate')
|
||||
try {
|
||||
await previewMutations.ai.mutateAsync()
|
||||
} finally {
|
||||
finishAiCandidateTask()
|
||||
}
|
||||
}
|
||||
|
||||
function removeCandidate(id: string): void {
|
||||
@@ -431,7 +569,6 @@ function removeCandidate(id: string): void {
|
||||
}
|
||||
|
||||
function refreshCandidate(row: CandidateRow): void {
|
||||
const wasSkipped = row.suggest_skip
|
||||
row.text = row.text.trim()
|
||||
const key = normalizeQuestionKey(row.text)
|
||||
const duplicateInBatch = candidateRows.value.some(
|
||||
@@ -440,11 +577,6 @@ function refreshCandidate(row: CandidateRow): void {
|
||||
row.too_short = row.text.length < 4
|
||||
row.duplicate = Boolean(key) && (existingQuestionSet.value.has(key) || duplicateInBatch)
|
||||
row.suggest_skip = row.too_short || row.duplicate || !questionLooksLikeQuestion(row.text)
|
||||
if (row.suggest_skip) {
|
||||
row.selected = false
|
||||
} else if (wasSkipped) {
|
||||
row.selected = true
|
||||
}
|
||||
}
|
||||
|
||||
function refreshAllCandidates(): void {
|
||||
@@ -462,13 +594,20 @@ function stopEditingCandidate(row: CandidateRow): void {
|
||||
|
||||
function updateCandidateSelection(row: CandidateRow, checked: boolean): void {
|
||||
refreshCandidate(row)
|
||||
if (row.suggest_skip && checked) {
|
||||
pageNotice.value = t('brands.questions.errors.invalidCandidate')
|
||||
if (checked && !row.selected && !canSelectMoreCandidates.value) {
|
||||
pageNotice.value = t('brands.questions.messages.selectionLimitReached', {
|
||||
limit: maxQuestions.value,
|
||||
})
|
||||
return
|
||||
}
|
||||
pageNotice.value = ''
|
||||
row.selected = checked
|
||||
}
|
||||
|
||||
function isCandidateSelectionDisabled(row: CandidateRow): boolean {
|
||||
return !row.selected && !canSelectMoreCandidates.value
|
||||
}
|
||||
|
||||
async function saveCandidates(): Promise<void> {
|
||||
refreshAllCandidates()
|
||||
if (!selectedCandidates.value.length) {
|
||||
@@ -570,8 +709,19 @@ function backToBrands(): void {
|
||||
<h3>{{ t('brands.questions.modal.methods.combination') }}</h3>
|
||||
<p>{{ t('brands.questions.page.expansionHint') }}</p>
|
||||
</div>
|
||||
<div class="tool-estimate">
|
||||
{{ t('brands.questions.combination.estimate', { count: expansionToolPreviewCount }) }}
|
||||
<div class="tool-header-actions">
|
||||
<a-button
|
||||
class="tool-ai-fill"
|
||||
:loading="combinationFillMutation.isPending.value"
|
||||
:disabled="previewMutations.combination.isPending.value"
|
||||
@click="fillCombinationWords"
|
||||
>
|
||||
<template #icon><RobotOutlined /></template>
|
||||
{{ t('brands.questions.aiFill.button') }}
|
||||
</a-button>
|
||||
<div class="tool-estimate">
|
||||
{{ t('brands.questions.combination.estimate', { count: expansionToolPreviewCount }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -740,10 +890,10 @@ function backToBrands(): void {
|
||||
v-for="row in candidateRows"
|
||||
:key="row.id"
|
||||
class="candidate-row"
|
||||
:class="{ 'candidate-row--muted': row.suggest_skip }"
|
||||
>
|
||||
<a-checkbox
|
||||
:checked="row.selected"
|
||||
:disabled="isCandidateSelectionDisabled(row)"
|
||||
@change="(event: Event) => updateCandidateSelection(row, (event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<a-input
|
||||
@@ -784,11 +934,11 @@ function backToBrands(): void {
|
||||
<div class="footer-meta">
|
||||
{{
|
||||
t('brands.questions.usage', {
|
||||
used: usedQuestions,
|
||||
used: displayUsedQuestions,
|
||||
total: maxQuestions || '--',
|
||||
})
|
||||
}}
|
||||
<span>{{ t('brands.questions.remaining', { count: remainingQuestions }) }}</span>
|
||||
<span>{{ t('brands.questions.remaining', { count: displayRemainingQuestions }) }}</span>
|
||||
</div>
|
||||
<div class="footer-actions">
|
||||
<a-button @click="backToBrands">{{ t('common.cancel') }}</a-button>
|
||||
@@ -817,6 +967,13 @@ function backToBrands(): void {
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<AiWaitingModal
|
||||
:open="aiCandidateModalOpen"
|
||||
:title="aiWaitingTitle"
|
||||
:progress="aiCandidateProgress"
|
||||
:stage="aiCandidateStages[aiCandidateStageIndex]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1010,6 +1167,45 @@ function backToBrands(): void {
|
||||
content: ':';
|
||||
}
|
||||
|
||||
.tool-header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tool-ai-fill {
|
||||
background: linear-gradient(135deg, #1677ff, #722ed1) !important;
|
||||
border: none !important;
|
||||
color: #ffffff !important;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(114, 46, 209, 0.25);
|
||||
transition: all 0.3s ease;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.tool-ai-fill:hover {
|
||||
background: linear-gradient(135deg, #2563eb, #8b5cf6) !important;
|
||||
box-shadow: 0 6px 16px rgba(114, 46, 209, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.tool-ai-fill[disabled],
|
||||
.tool-ai-fill.ant-btn-loading {
|
||||
background: #f1f5f9 !important;
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
color: #94a3b8 !important;
|
||||
box-shadow: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.tool-ai-fill :deep(.anticon) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.tool-estimate {
|
||||
min-width: 168px;
|
||||
padding: 8px 12px;
|
||||
@@ -1199,19 +1395,24 @@ function backToBrands(): void {
|
||||
}
|
||||
|
||||
.candidate-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.candidate-empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: 32px;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
background: #f8fafc;
|
||||
background: #ffffff;
|
||||
border: 1px dashed #d8e0ec;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.candidate-row {
|
||||
@@ -1219,18 +1420,21 @@ function backToBrands(): void {
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-height: 58px;
|
||||
padding: 12px 16px;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.candidate-row:last-child {
|
||||
border-bottom: none;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e6edf5;
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.candidate-row:hover {
|
||||
background-color: #f8fafc;
|
||||
background-color: #fbfdff;
|
||||
border-color: #b7d6ff;
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.candidate-row__text {
|
||||
@@ -1254,15 +1458,6 @@ function backToBrands(): void {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.candidate-row--muted {
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.candidate-row--muted .candidate-row__text {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.wizard-page__footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
@@ -1322,7 +1517,7 @@ function backToBrands(): void {
|
||||
}
|
||||
|
||||
.candidate-row {
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto;
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto auto;
|
||||
}
|
||||
|
||||
.wizard-page__footer {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import type {
|
||||
ArticleDetail,
|
||||
JsonValue,
|
||||
Question,
|
||||
TemplateAnalyzeResult,
|
||||
TemplateAssistCompetitor,
|
||||
TemplateOutlineNode,
|
||||
@@ -20,6 +21,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AiWaitingModal from '@/components/AiWaitingModal.vue'
|
||||
import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue'
|
||||
import { articlesApi, brandsApi, normalizeInputParams, templatesApi } from '@/lib/api'
|
||||
import { getTemplateMeta } from '@/lib/display'
|
||||
@@ -170,7 +172,8 @@ const brandName = ref('')
|
||||
const officialWebsite = ref('')
|
||||
const brandSummary = ref('')
|
||||
|
||||
const keywordDrafts = ref<string[]>([])
|
||||
const primaryQuestionId = ref<number | null>(null)
|
||||
const supplementalQuestionIds = ref<number[]>([])
|
||||
const competitorDrafts = ref<DraftCompetitor[]>([])
|
||||
|
||||
const assistTitles = ref<string[]>([])
|
||||
@@ -218,10 +221,10 @@ const draftArticleQuery = useQuery({
|
||||
queryFn: () => articlesApi.detail(draftArticleId.value as number),
|
||||
})
|
||||
|
||||
const keywordsQuery = useQuery({
|
||||
queryKey: computed(() => ['brands', selectedBrandId.value, 'keywords']),
|
||||
const questionsQuery = useQuery({
|
||||
queryKey: computed(() => ['brands', selectedBrandId.value, 'questions', 'wizard']),
|
||||
enabled: computed(() => enabled.value && Boolean(selectedBrandId.value)),
|
||||
queryFn: () => brandsApi.listKeywords(selectedBrandId.value as number),
|
||||
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
|
||||
})
|
||||
|
||||
const competitorsQuery = useQuery({
|
||||
@@ -284,18 +287,41 @@ const selectedBrand = computed(
|
||||
() => brandsQuery.data.value?.find((brand) => brand.id === selectedBrandId.value) ?? null,
|
||||
)
|
||||
|
||||
const keywordOptions = computed(() =>
|
||||
(keywordsQuery.data.value ?? []).map((item) => ({
|
||||
label: item.name,
|
||||
value: item.name,
|
||||
function findQuestionById(id: number | null): Question | null {
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
return questionsQuery.data.value?.find((item) => item.id === id) ?? null
|
||||
}
|
||||
|
||||
const questionOptions = computed(() =>
|
||||
(questionsQuery.data.value ?? []).map((item) => ({
|
||||
label: item.question_text,
|
||||
value: item.id,
|
||||
})),
|
||||
)
|
||||
const supplementalQuestionOptions = computed(() =>
|
||||
questionOptions.value.filter((item) => item.value !== primaryQuestionId.value),
|
||||
)
|
||||
const primaryQuestion = computed(() => findQuestionById(primaryQuestionId.value))
|
||||
const supplementalQuestions = computed(() =>
|
||||
supplementalQuestionIds.value
|
||||
.map((id) => findQuestionById(id))
|
||||
.filter((item): item is Question => Boolean(item)),
|
||||
)
|
||||
const primaryQuestionText = computed(() => primaryQuestion.value?.question_text.trim() || '')
|
||||
const supplementalQuestionTexts = computed(() =>
|
||||
supplementalQuestions.value.map((item) => item.question_text.trim()).filter(Boolean),
|
||||
)
|
||||
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(() => keywordDrafts.value[0]?.trim() || '')
|
||||
const primaryKeyword = computed(() => primaryQuestionText.value)
|
||||
const showCompetitorsCard = computed(
|
||||
() =>
|
||||
!isResearchReportTemplate.value &&
|
||||
@@ -333,7 +359,8 @@ const templateRenderContext = computed(() =>
|
||||
brandName: normalizedBrandName.value,
|
||||
officialWebsite: officialWebsite.value,
|
||||
brandSummary: brandSummary.value,
|
||||
primaryKeyword: primaryKeyword.value,
|
||||
primaryQuestion: primaryQuestionText.value,
|
||||
supplementalQuestions: supplementalQuestionTexts.value,
|
||||
inputParams: buildStructureInputParams(),
|
||||
competitorCount: buildCompetitorPayload().length,
|
||||
}),
|
||||
@@ -389,16 +416,16 @@ const generateStepCopy = computed<TemplateStepConfig>(
|
||||
() => wizardConfig.value?.steps?.generate ?? {},
|
||||
)
|
||||
const brandCardCopy = computed<TemplateCardCopy>(
|
||||
() => wizardConfig.value?.basic?.cards?.brand ?? {},
|
||||
() => sanitizeLegacyKeywordCopy(wizardConfig.value?.basic?.cards?.brand ?? {}),
|
||||
)
|
||||
const keywordCardCopy = computed<TemplateCardCopy>(
|
||||
() => wizardConfig.value?.basic?.cards?.keywords ?? {},
|
||||
const questionCardCopy = computed<TemplateCardCopy>(
|
||||
() => sanitizeQuestionCardCopy(wizardConfig.value?.basic?.cards?.keywords ?? {}),
|
||||
)
|
||||
const competitorsCardCopy = computed<TemplateCardCopy>(
|
||||
() => wizardConfig.value?.basic?.cards?.competitors ?? {},
|
||||
)
|
||||
const titleCardCopy = computed<TemplateCardCopy>(
|
||||
() => wizardConfig.value?.structure?.cards?.choose_title ?? {},
|
||||
() => sanitizeLegacyKeywordCopy(wizardConfig.value?.structure?.cards?.choose_title ?? {}),
|
||||
)
|
||||
const outlineCardCopy = computed<TemplateCardCopy>(
|
||||
() => wizardConfig.value?.structure?.cards?.outline ?? {},
|
||||
@@ -442,7 +469,8 @@ const hasWizardDraftContent = computed(
|
||||
Boolean(brandName.value.trim()) ||
|
||||
Boolean(officialWebsite.value.trim()) ||
|
||||
Boolean(brandSummary.value.trim()) ||
|
||||
keywordDrafts.value.some((item) => item.trim()) ||
|
||||
Boolean(primaryQuestionId.value) ||
|
||||
supplementalQuestionIds.value.length > 0 ||
|
||||
competitorDrafts.value.some(hasCompetitorDraftContent) ||
|
||||
assistTitles.value.length > 0 ||
|
||||
Boolean(customTitle.value.trim()) ||
|
||||
@@ -456,7 +484,7 @@ const assistStages = computed(() =>
|
||||
assistTaskKind.value === 'analyze'
|
||||
? [
|
||||
t('templates.wizard.assist.analyzeStages.brand'),
|
||||
t('templates.wizard.assist.analyzeStages.keywords'),
|
||||
t('templates.wizard.assist.analyzeStages.questions'),
|
||||
showCompetitorsCard.value
|
||||
? t('templates.wizard.assist.analyzeStages.competitors')
|
||||
: t('templates.wizard.assist.analyzeStages.context'),
|
||||
@@ -495,7 +523,8 @@ function resetWizardState(detail: NonNullable<typeof templateDetail.value>): voi
|
||||
brandName.value = ''
|
||||
officialWebsite.value = ''
|
||||
brandSummary.value = ''
|
||||
keywordDrafts.value = []
|
||||
primaryQuestionId.value = null
|
||||
supplementalQuestionIds.value = []
|
||||
competitorDrafts.value = []
|
||||
assistTitles.value = []
|
||||
selectedTitle.value = ''
|
||||
@@ -515,7 +544,8 @@ function resetWizardState(detail: NonNullable<typeof templateDetail.value>): voi
|
||||
brandName: '',
|
||||
officialWebsite: '',
|
||||
brandSummary: '',
|
||||
primaryKeyword: '',
|
||||
primaryQuestion: '',
|
||||
supplementalQuestions: [],
|
||||
inputParams: {},
|
||||
competitorCount: 0,
|
||||
}),
|
||||
@@ -530,7 +560,8 @@ function resetWizardState(detail: NonNullable<typeof templateDetail.value>): voi
|
||||
brandName: '',
|
||||
officialWebsite: '',
|
||||
brandSummary: '',
|
||||
primaryKeyword: '',
|
||||
primaryQuestion: '',
|
||||
supplementalQuestions: [],
|
||||
inputParams: {},
|
||||
competitorCount: 0,
|
||||
}),
|
||||
@@ -553,7 +584,9 @@ function applyDraftArticleState(detail: ArticleDetail): void {
|
||||
brandName.value = stringFromUnknown(draftState.brand_name) || ''
|
||||
officialWebsite.value = stringFromUnknown(draftState.official_website) || ''
|
||||
brandSummary.value = stringFromUnknown(draftState.brand_summary) || ''
|
||||
keywordDrafts.value = stringListFromUnknown(draftState.keyword_drafts) ?? []
|
||||
primaryQuestionId.value = numberFromUnknown(draftState.primary_question_id) ?? null
|
||||
supplementalQuestionIds.value =
|
||||
numberListFromUnknown(draftState.supplemental_question_ids)?.slice(0, 3) ?? []
|
||||
competitorDrafts.value = parseDraftCompetitors(draftState.competitor_drafts)
|
||||
assistTitles.value = stringListFromUnknown(draftState.assist_titles) ?? []
|
||||
selectedTitle.value = stringFromUnknown(draftState.selected_title) || ''
|
||||
@@ -619,6 +652,29 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(primaryQuestionId, (id) => {
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
supplementalQuestionIds.value = supplementalQuestionIds.value.filter((item) => item !== id)
|
||||
})
|
||||
|
||||
watch(supplementalQuestionIds, (ids) => {
|
||||
const optionValues = supplementalQuestionOptions.value.map((item) => item.value)
|
||||
const allowedIds = new Set(optionValues)
|
||||
const shouldCheckOptions = optionValues.length > 0
|
||||
const normalized = ids.filter(
|
||||
(id, index) =>
|
||||
id !== primaryQuestionId.value &&
|
||||
ids.indexOf(id) === index &&
|
||||
(!shouldCheckOptions || allowedIds.has(id)),
|
||||
)
|
||||
const limited = normalized.slice(0, 3)
|
||||
if (limited.length !== ids.length || limited.some((id, index) => id !== ids[index])) {
|
||||
supplementalQuestionIds.value = limited
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedBrandId, async (brandId) => {
|
||||
if (restoringDraft.value) {
|
||||
return
|
||||
@@ -643,9 +699,9 @@ watch(selectedBrandId, async (brandId) => {
|
||||
brandSummary.value = brand.description
|
||||
}
|
||||
|
||||
const keywordsResult = await keywordsQuery.refetch()
|
||||
|
||||
keywordDrafts.value = dedupeStrings((keywordsResult.data ?? []).map((item) => item.name))
|
||||
primaryQuestionId.value = null
|
||||
supplementalQuestionIds.value = []
|
||||
await questionsQuery.refetch()
|
||||
|
||||
if (!showCompetitorsCard.value) {
|
||||
competitorDrafts.value = []
|
||||
@@ -759,6 +815,14 @@ function validateBasicInfo(): boolean {
|
||||
message.warning(t('templates.wizard.messages.missingBrand'))
|
||||
return false
|
||||
}
|
||||
if (!selectedBrandId.value) {
|
||||
message.warning(t('templates.wizard.messages.selectBrandBeforeQuestion'))
|
||||
return false
|
||||
}
|
||||
if (!primaryQuestionId.value || !primaryQuestionText.value) {
|
||||
message.warning(t('templates.wizard.messages.missingPrimaryQuestion'))
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -859,8 +923,8 @@ async function runAnalyzeTask(notifySuccess: boolean): Promise<void> {
|
||||
brand_id: selectedBrandId.value,
|
||||
brand_name: normalizedBrandName.value,
|
||||
website: officialWebsite.value.trim() || null,
|
||||
brand_question: primaryQuestionText.value,
|
||||
input_params: buildAssistInputParams(),
|
||||
existing_keywords: keywordDrafts.value,
|
||||
existing_competitors: competitorPayload.length > 0 ? competitorPayload : undefined,
|
||||
})
|
||||
|
||||
@@ -893,8 +957,9 @@ async function runTitleTask(): Promise<void> {
|
||||
brand_name: normalizedBrandName.value,
|
||||
website: officialWebsite.value.trim() || null,
|
||||
brand_summary: brandSummary.value.trim() || null,
|
||||
input_params: buildAssistInputParams(),
|
||||
keywords: keywordDrafts.value,
|
||||
brand_question: primaryQuestionText.value,
|
||||
input_params: buildTitleInputParams(),
|
||||
keywords: primaryQuestionText.value ? [primaryQuestionText.value] : [],
|
||||
competitors: competitorPayload.length > 0 ? competitorPayload : undefined,
|
||||
})
|
||||
|
||||
@@ -928,8 +993,10 @@ async function runOutlineTask(): Promise<void> {
|
||||
website: officialWebsite.value.trim() || null,
|
||||
brand_summary: brandSummary.value.trim() || null,
|
||||
title: finalTitle.value,
|
||||
brand_question: primaryQuestionText.value,
|
||||
supplemental_questions: supplementalQuestionTexts.value,
|
||||
input_params: buildStructureInputParams(),
|
||||
keywords: keywordDrafts.value,
|
||||
keywords: selectedQuestionTexts.value,
|
||||
competitors: competitorPayload.length > 0 ? competitorPayload : undefined,
|
||||
outline_sections: selectedOutlineLabels.value,
|
||||
})
|
||||
@@ -1019,7 +1086,6 @@ function applyAnalyzeResult(result: TemplateAnalyzeResult): void {
|
||||
brandSummary.value = result.brand_summary.trim()
|
||||
}
|
||||
|
||||
keywordDrafts.value = dedupeStrings([...keywordDrafts.value, ...(result.keywords ?? [])])
|
||||
if (showCompetitorsCard.value) {
|
||||
competitorDrafts.value = mergeCompetitors(competitorDrafts.value, result.competitors ?? [])
|
||||
}
|
||||
@@ -1097,8 +1163,42 @@ function buildCompetitorPayload(): TemplateAssistCompetitor[] {
|
||||
}))
|
||||
}
|
||||
|
||||
function sanitizeQuestionCardCopy(copy: TemplateCardCopy): TemplateCardCopy {
|
||||
if (copy.title?.includes('关键词') || copy.hint?.includes('关键词')) {
|
||||
return {
|
||||
...copy,
|
||||
title: undefined,
|
||||
hint: undefined,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...copy,
|
||||
title: sanitizeLegacyKeywordText(copy.title) || undefined,
|
||||
hint: sanitizeLegacyKeywordText(copy.hint) || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeLegacyKeywordCopy(copy: TemplateCardCopy): TemplateCardCopy {
|
||||
return {
|
||||
...copy,
|
||||
title: sanitizeLegacyKeywordText(copy.title) || undefined,
|
||||
hint: sanitizeLegacyKeywordText(copy.hint) || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeLegacyKeywordText(value: string | undefined): string {
|
||||
return (value || '')
|
||||
.replace(/核心关键词/g, '品牌主问题')
|
||||
.replace(/关键词/g, '品牌问题')
|
||||
.replace(/标题生成/g, '后续生成')
|
||||
}
|
||||
|
||||
function buildAssistInputParams(): Record<string, JsonValue> {
|
||||
const payload: Record<string, JsonValue> = {}
|
||||
const payload: Record<string, JsonValue> = {
|
||||
brand_question: primaryQuestionText.value,
|
||||
primary_question: primaryQuestionText.value,
|
||||
primary_keyword: primaryQuestionText.value,
|
||||
}
|
||||
const derivedInputs = wizardConfig.value?.derived_inputs ?? {}
|
||||
for (const [key, config] of Object.entries(derivedInputs)) {
|
||||
const value = resolveDerivedInputValue(key, config)
|
||||
@@ -1110,8 +1210,16 @@ function buildAssistInputParams(): Record<string, JsonValue> {
|
||||
return payload
|
||||
}
|
||||
|
||||
function buildTitleInputParams(): Record<string, JsonValue> {
|
||||
return buildAssistInputParams()
|
||||
}
|
||||
|
||||
function buildStructureInputParams(): Record<string, JsonValue> {
|
||||
const payload = buildAssistInputParams()
|
||||
if (supplementalQuestionTexts.value.length > 0) {
|
||||
payload.supplemental_questions = supplementalQuestionTexts.value
|
||||
payload.keywords = selectedQuestionTexts.value
|
||||
}
|
||||
if (reviewIntroHookPromptValue.value) {
|
||||
payload[reviewIntroHookFieldName.value] = reviewIntroHookPromptValue.value
|
||||
}
|
||||
@@ -1141,8 +1249,12 @@ function buildPayload(): Record<string, JsonValue> {
|
||||
brand_name: normalizedBrandName.value || undefined,
|
||||
brand_summary: brandSummary.value.trim() || undefined,
|
||||
official_website: officialWebsite.value.trim() || undefined,
|
||||
primary_keyword: primaryKeyword.value || undefined,
|
||||
keywords: keywordDrafts.value.length > 0 ? dedupeStrings(keywordDrafts.value) : undefined,
|
||||
brand_question: primaryQuestionText.value || undefined,
|
||||
primary_question: primaryQuestionText.value || undefined,
|
||||
supplemental_questions:
|
||||
supplementalQuestionTexts.value.length > 0 ? supplementalQuestionTexts.value : undefined,
|
||||
primary_keyword: primaryQuestionText.value || undefined,
|
||||
keywords: selectedQuestionTexts.value.length > 0 ? selectedQuestionTexts.value : undefined,
|
||||
[reviewIntroHookFieldName.value]: reviewIntroHookPromptValue.value || undefined,
|
||||
knowledge_group_ids:
|
||||
selectedKnowledgeGroupIds.value.length > 0 ? selectedKnowledgeGroupIds.value : undefined,
|
||||
@@ -1173,7 +1285,10 @@ function buildDraftState(): Record<string, JsonValue> {
|
||||
brand_name: brandName.value.trim(),
|
||||
official_website: officialWebsite.value.trim(),
|
||||
brand_summary: brandSummary.value.trim(),
|
||||
keyword_drafts: dedupeStrings(keywordDrafts.value),
|
||||
primary_question_id: primaryQuestionId.value,
|
||||
primary_question_text: primaryQuestionText.value,
|
||||
supplemental_question_ids: supplementalQuestionIds.value,
|
||||
supplemental_question_texts: supplementalQuestionTexts.value,
|
||||
competitor_drafts: showCompetitorsCard.value
|
||||
? competitorDrafts.value.map((item) => ({
|
||||
library_id: item.libraryId ?? null,
|
||||
@@ -1945,17 +2060,23 @@ function buildTemplateRenderContext(input: {
|
||||
brandName: string
|
||||
officialWebsite: string
|
||||
brandSummary: string
|
||||
primaryKeyword: string
|
||||
primaryQuestion: string
|
||||
supplementalQuestions: string[]
|
||||
inputParams: Record<string, JsonValue>
|
||||
competitorCount: number
|
||||
}): Record<string, string> {
|
||||
const primaryQuestion = input.primaryQuestion.trim()
|
||||
const supplementalQuestions = dedupeStrings(input.supplementalQuestions)
|
||||
const context: Record<string, string> = {
|
||||
locale: input.locale.trim(),
|
||||
current_year: String(new Date().getFullYear()),
|
||||
brand_name: input.brandName.trim(),
|
||||
official_website: input.officialWebsite.trim(),
|
||||
brand_summary: input.brandSummary.trim(),
|
||||
primary_keyword: input.primaryKeyword.trim() || input.brandName.trim(),
|
||||
brand_question: primaryQuestion,
|
||||
primary_question: primaryQuestion,
|
||||
primary_keyword: primaryQuestion || input.brandName.trim(),
|
||||
supplemental_questions: supplementalQuestions.join(', '),
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(input.inputParams)) {
|
||||
@@ -2338,17 +2459,60 @@ function onStructureDragEnd(): void {
|
||||
<div class="wizard-card">
|
||||
<div class="wizard-card__header">
|
||||
<div>
|
||||
<h3>{{ keywordCardCopy.title || t('templates.wizard.sections.keywords') }}</h3>
|
||||
<p>{{ keywordCardCopy.hint || t('templates.wizard.hints.keywords') }}</p>
|
||||
<h3>
|
||||
{{ questionCardCopy.title || t('templates.wizard.sections.brandQuestions') }}
|
||||
</h3>
|
||||
<p>
|
||||
{{ questionCardCopy.hint || t('templates.wizard.hints.brandQuestions') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="brand-question-panel">
|
||||
<div class="field-item">
|
||||
<label class="required-asterisk">
|
||||
{{ t('templates.wizard.sections.primaryQuestion') }}
|
||||
</label>
|
||||
<a-select
|
||||
v-model:value="primaryQuestionId"
|
||||
allow-clear
|
||||
show-search
|
||||
:disabled="!selectedBrandId"
|
||||
:loading="questionsQuery.isPending.value"
|
||||
:not-found-content="
|
||||
selectedBrandId
|
||||
? t('templates.wizard.hints.questionEmpty')
|
||||
: t('templates.wizard.hints.selectBrandFirst')
|
||||
"
|
||||
:options="questionOptions"
|
||||
:placeholder="t('templates.wizard.placeholders.primaryQuestion')"
|
||||
option-filter-prop="label"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-item">
|
||||
<div class="optional-field-label">
|
||||
<label>{{ t('templates.wizard.sections.supplementalQuestions') }}</label>
|
||||
<span>{{ supplementalQuestionIds.length }}/3</span>
|
||||
</div>
|
||||
<a-select
|
||||
v-model:value="supplementalQuestionIds"
|
||||
mode="multiple"
|
||||
show-search
|
||||
:disabled="!selectedBrandId || !primaryQuestionId"
|
||||
:loading="questionsQuery.isPending.value"
|
||||
:max-tag-count="3"
|
||||
:not-found-content="t('templates.wizard.hints.questionEmpty')"
|
||||
:options="supplementalQuestionOptions"
|
||||
:placeholder="t('templates.wizard.placeholders.supplementalQuestions')"
|
||||
option-filter-prop="label"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<p class="field-helper">
|
||||
{{ t('templates.wizard.hints.supplementalQuestions') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a-select
|
||||
v-model:value="keywordDrafts"
|
||||
mode="tags"
|
||||
:options="keywordOptions"
|
||||
:placeholder="t('templates.wizard.placeholders.keywords')"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="showCompetitorsCard" class="wizard-card">
|
||||
@@ -2789,22 +2953,12 @@ function onStructureDragEnd(): void {
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<a-modal
|
||||
<AiWaitingModal
|
||||
:open="assistModalOpen"
|
||||
:footer="null"
|
||||
:closable="false"
|
||||
:mask-closable="false"
|
||||
centered
|
||||
width="640px"
|
||||
wrap-class-name="wizard-assist-modal"
|
||||
>
|
||||
<div class="assist-modal">
|
||||
<div class="assist-orb" />
|
||||
<h3>{{ assistHeading }}</h3>
|
||||
<a-progress :percent="assistProgress" status="active" :show-info="false" />
|
||||
<p>{{ assistStages[assistStageIndex] }}</p>
|
||||
</div>
|
||||
</a-modal>
|
||||
:title="assistHeading"
|
||||
:progress="assistProgress"
|
||||
:stage="assistStages[assistStageIndex]"
|
||||
/>
|
||||
|
||||
<a-modal
|
||||
:open="leaveConfirmOpen"
|
||||
@@ -2997,12 +3151,50 @@ function onStructureDragEnd(): void {
|
||||
content: ':';
|
||||
}
|
||||
|
||||
.optional-field-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.optional-field-label label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #1f2937;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.optional-field-label label::after {
|
||||
content: ':';
|
||||
}
|
||||
|
||||
.optional-field-label span {
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.field-helper {
|
||||
margin: 0;
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.required-asterisk::before {
|
||||
margin-right: 4px;
|
||||
color: #ff4d4f;
|
||||
content: '*';
|
||||
}
|
||||
|
||||
.brand-question-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.wizard-ai-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3574,51 +3766,6 @@ function onStructureDragEnd(): void {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.assist-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 24px 20px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.assist-orb {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.92), transparent 38%),
|
||||
conic-gradient(from 120deg, #4f8fff, #7dd3fc, #e879f9, #4f8fff);
|
||||
box-shadow:
|
||||
0 18px 38px rgba(79, 143, 255, 0.2),
|
||||
inset 0 0 18px rgba(255, 255, 255, 0.4);
|
||||
animation: orb-float 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.assist-modal h3 {
|
||||
margin: 0;
|
||||
color: #1b2741;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.assist-modal p {
|
||||
margin: 0;
|
||||
color: #5f6f8a;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
@keyframes orb-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px) scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.wizard-section--structure {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -3628,6 +3775,10 @@ function onStructureDragEnd(): void {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.brand-question-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.competitor-table__head,
|
||||
.competitor-table__row {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -3678,9 +3829,5 @@ function onStructureDragEnd(): void {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.assist-modal h3 {
|
||||
font-size: 26px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -96,9 +96,10 @@ const trackingPlatformOptions = [
|
||||
value: platform.id,
|
||||
})),
|
||||
] as const
|
||||
const allQuestionOptionValue = 'all'
|
||||
|
||||
const selectedBrandId = useStorage<number | null>('tracking_selected_brand', null)
|
||||
const selectedKeywordId = useStorage<number | null>('tracking_selected_keyword', null)
|
||||
const selectedQuestionId = useStorage<number | null>('tracking_selected_question', null)
|
||||
const selectedPlatformId = useStorage<string>('tracking_selected_ai_platform_id', 'all')
|
||||
const selectedBusinessDate = useStorage<string>('tracking_selected_business_date', trackingToday)
|
||||
const selectedCitationWindowDays = useStorage<number>('tracking_selected_citation_window_days', 7)
|
||||
@@ -108,6 +109,14 @@ selectedBusinessDate.value =
|
||||
normalizeTrackingBusinessDate(selectedBusinessDate.value) ?? trackingToday
|
||||
selectedCitationWindowDays.value = normalizeCitationWindowDays(selectedCitationWindowDays.value)
|
||||
|
||||
const selectedQuestionSetOptionValue = computed<string | number>({
|
||||
get: () => selectedQuestionId.value ?? allQuestionOptionValue,
|
||||
set: (value) => {
|
||||
selectedQuestionId.value =
|
||||
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
|
||||
},
|
||||
})
|
||||
|
||||
const brandsQuery = useQuery({
|
||||
queryKey: ['tracking', 'brands'],
|
||||
queryFn: () => brandsApi.list(),
|
||||
@@ -135,37 +144,48 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const keywordsQuery = useQuery({
|
||||
queryKey: computed(() => ['tracking', 'keywords', selectedBrandId.value]),
|
||||
const questionsQuery = useQuery({
|
||||
queryKey: computed(() => ['tracking', 'questions', selectedBrandId.value]),
|
||||
enabled: computed(() => Boolean(selectedBrandId.value)),
|
||||
queryFn: () => brandsApi.listKeywords(selectedBrandId.value as number),
|
||||
queryFn: () => brandsApi.listQuestions(selectedBrandId.value as number),
|
||||
})
|
||||
|
||||
const questionSetOptions = computed(() => [
|
||||
{ label: t('tracking.allQuestionSets'), value: allQuestionOptionValue },
|
||||
...(questionsQuery.data.value ?? []).map((item) => ({
|
||||
label: item.question_text,
|
||||
value: item.id,
|
||||
})),
|
||||
])
|
||||
|
||||
watch(
|
||||
() => keywordsQuery.data.value,
|
||||
(keywords) => {
|
||||
if (!keywords?.length) {
|
||||
() => questionsQuery.data.value,
|
||||
(questions) => {
|
||||
const requestedQuestionId = parseNumericQuery(route.query.question_id)
|
||||
if (!questions?.length) {
|
||||
if (!requestedQuestionId) {
|
||||
selectedQuestionId.value = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const requestedKeywordId = parseNumericQuery(route.query.keyword_id)
|
||||
if (requestedKeywordId && keywords.some((item) => item.id === requestedKeywordId)) {
|
||||
selectedKeywordId.value = requestedKeywordId
|
||||
if (requestedQuestionId && questions.some((item) => item.id === requestedQuestionId)) {
|
||||
selectedQuestionId.value = requestedQuestionId
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedKeywordId.value && keywords.some((item) => item.id === selectedKeywordId.value)) {
|
||||
if (selectedQuestionId.value && questions.some((item) => item.id === selectedQuestionId.value)) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedKeywordId.value = keywords[0].id
|
||||
selectedQuestionId.value = null
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(selectedBrandId, (newId, oldId) => {
|
||||
if (oldId && newId !== oldId) {
|
||||
selectedKeywordId.value = null
|
||||
selectedQuestionId.value = null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -189,14 +209,25 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
[selectedBrandId, selectedKeywordId, selectedPlatformId, selectedBusinessDate],
|
||||
([brandId, keywordId, platformId, businessDate]) => {
|
||||
[
|
||||
selectedBrandId,
|
||||
selectedQuestionId,
|
||||
selectedPlatformId,
|
||||
selectedBusinessDate,
|
||||
() => questionsQuery.data.value,
|
||||
],
|
||||
([brandId, questionId, platformId, businessDate, questions]) => {
|
||||
const requestedQuestionId = parseNumericQuery(route.query.question_id)
|
||||
if (requestedQuestionId && !questionId && !Array.isArray(questions)) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextBrandId = brandId ? String(brandId) : undefined
|
||||
const nextKeywordId = keywordId ? String(keywordId) : undefined
|
||||
const nextQuestionId = questionId ? String(questionId) : undefined
|
||||
const nextPlatformId = platformId !== 'all' ? platformId : undefined
|
||||
const nextBusinessDate = normalizeTrackingBusinessDate(businessDate) ?? trackingToday
|
||||
const currentBrandId = normalizeQueryValue(route.query.brand_id) || undefined
|
||||
const currentKeywordId = normalizeQueryValue(route.query.keyword_id) || undefined
|
||||
const currentQuestionId = normalizeQueryValue(route.query.question_id) || undefined
|
||||
const currentPlatformId = normalizeMonitoringPlatformFilter(route.query.ai_platform_id)
|
||||
const currentBusinessDate =
|
||||
normalizeTrackingBusinessDate(route.query.business_date) ?? trackingToday
|
||||
@@ -204,7 +235,7 @@ watch(
|
||||
|
||||
if (
|
||||
currentBrandId === nextBrandId &&
|
||||
currentKeywordId === nextKeywordId &&
|
||||
currentQuestionId === nextQuestionId &&
|
||||
currentPlatformId === (nextPlatformId ?? 'all') &&
|
||||
currentBusinessDate === nextBusinessDate &&
|
||||
!hasLegacyCitationDaysQuery
|
||||
@@ -216,7 +247,7 @@ watch(
|
||||
name: 'tracking',
|
||||
query: {
|
||||
...(nextBrandId ? { brand_id: nextBrandId } : {}),
|
||||
...(nextKeywordId ? { keyword_id: nextKeywordId } : {}),
|
||||
...(nextQuestionId ? { question_id: nextQuestionId } : {}),
|
||||
...(nextPlatformId ? { ai_platform_id: nextPlatformId } : {}),
|
||||
business_date: nextBusinessDate,
|
||||
},
|
||||
@@ -229,7 +260,7 @@ const dashboardQuery = useQuery({
|
||||
'tracking',
|
||||
'dashboard',
|
||||
selectedBrandId.value,
|
||||
selectedKeywordId.value,
|
||||
selectedQuestionId.value,
|
||||
selectedPlatformId.value,
|
||||
selectedBusinessDate.value,
|
||||
]),
|
||||
@@ -237,7 +268,7 @@ const dashboardQuery = useQuery({
|
||||
queryFn: () =>
|
||||
monitoringApi.dashboardComposite({
|
||||
brand_id: selectedBrandId.value ?? undefined,
|
||||
keyword_id: selectedKeywordId.value,
|
||||
question_id: selectedQuestionId.value,
|
||||
days: trackingMaxHistoryDays,
|
||||
ai_platform_id: selectedPlatformId.value !== 'all' ? selectedPlatformId.value : undefined,
|
||||
business_date: selectedBusinessDate.value,
|
||||
@@ -249,7 +280,7 @@ const citationSummaryQuery = useQuery({
|
||||
'tracking',
|
||||
'citation-summary',
|
||||
selectedBrandId.value,
|
||||
selectedKeywordId.value,
|
||||
selectedQuestionId.value,
|
||||
selectedPlatformId.value,
|
||||
selectedBusinessDate.value,
|
||||
selectedCitationWindowDays.value,
|
||||
@@ -258,7 +289,7 @@ const citationSummaryQuery = useQuery({
|
||||
queryFn: () =>
|
||||
monitoringApi.citationSummary({
|
||||
brand_id: selectedBrandId.value ?? undefined,
|
||||
keyword_id: selectedKeywordId.value,
|
||||
question_id: selectedQuestionId.value,
|
||||
business_date: selectedBusinessDate.value,
|
||||
ai_platform_id: selectedPlatformId.value !== 'all' ? selectedPlatformId.value : undefined,
|
||||
days: selectedCitationWindowDays.value,
|
||||
@@ -275,12 +306,9 @@ const collectNowMutation = useMutation({
|
||||
if (!selectedBrandId.value) {
|
||||
throw new Error('tracking_brand_required')
|
||||
}
|
||||
if (!selectedKeywordId.value) {
|
||||
throw new Error('tracking_keyword_required')
|
||||
}
|
||||
|
||||
return monitoringApi.collectNow(selectedBrandId.value, {
|
||||
keyword_id: selectedKeywordId.value,
|
||||
question_id: selectedQuestionId.value ?? undefined,
|
||||
platform_ids: selectedPlatformId.value !== 'all' ? [selectedPlatformId.value] : undefined,
|
||||
})
|
||||
},
|
||||
@@ -293,10 +321,6 @@ const collectNowMutation = useMutation({
|
||||
message.warning(t('tracking.collectBrandRequired'))
|
||||
return
|
||||
}
|
||||
if (error instanceof Error && error.message === 'tracking_keyword_required') {
|
||||
message.warning(t('tracking.collectKeywordRequired'))
|
||||
return
|
||||
}
|
||||
message.error(formatError(error))
|
||||
},
|
||||
})
|
||||
@@ -343,9 +367,6 @@ const collectNowDisabledReason = computed(() => {
|
||||
if (!selectedBrandId.value) {
|
||||
return t('tracking.collectBrandRequired')
|
||||
}
|
||||
if (!selectedKeywordId.value) {
|
||||
return t('tracking.collectKeywordRequired')
|
||||
}
|
||||
if (selectedBusinessDate.value !== trackingToday) {
|
||||
return t('tracking.collectTodayOnly')
|
||||
}
|
||||
@@ -649,11 +670,20 @@ function openQuestion(question: MonitoringHotQuestion): void {
|
||||
date_from: selectedBusinessDate.value,
|
||||
date_to: selectedBusinessDate.value,
|
||||
...(selectedPlatformId.value !== 'all' ? { ai_platform_id: selectedPlatformId.value } : {}),
|
||||
...(selectedKeywordId.value ? { keyword_id: String(selectedKeywordId.value) } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function filterQuestionOption(input: string, option?: { label?: string }): boolean {
|
||||
const keyword = input.trim().toLowerCase()
|
||||
if (!keyword) {
|
||||
return true
|
||||
}
|
||||
return String(option?.label ?? '')
|
||||
.toLowerCase()
|
||||
.includes(keyword)
|
||||
}
|
||||
|
||||
function formatPercent(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '--'
|
||||
@@ -832,18 +862,16 @@ 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="selectedKeywordId"
|
||||
allow-clear
|
||||
v-model:value="selectedQuestionSetOptionValue"
|
||||
show-search
|
||||
class="tracking-select"
|
||||
option-filter-prop="label"
|
||||
:filter-option="filterQuestionOption"
|
||||
:not-found-content="t('tracking.noQuestions')"
|
||||
:placeholder="t('tracking.keywordPlaceholder')"
|
||||
:options="
|
||||
(keywordsQuery.data.value ?? []).map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}))
|
||||
"
|
||||
:options="questionSetOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="tracking-action-item">
|
||||
|
||||
Reference in New Issue
Block a user