feat(questions): make brand questions the primary monitoring & template axis
Deployment Config CI / Deployment Config (push) Successful in 29s
Frontend CI / Frontend (push) Successful in 4m4s
Backend CI / Backend (push) Failing after 7m12s

- 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:
2026-05-13 15:59:39 +08:00
parent 37b0b32327
commit 1eae6fb6d4
36 changed files with 2119 additions and 412 deletions
@@ -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>
+61 -16
View File
@@ -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',
+57 -15
View File
@@ -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: "新建问题",
+18 -2
View File
@@ -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 ?? {})
},
}
+17 -2
View File
@@ -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 '发生未知错误'
+78 -12
View File
@@ -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 {
+254 -107
View File
@@ -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>
+72 -44
View File
@@ -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">