diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index f54f97d..4e77655 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -212,6 +212,10 @@ const enUS = { title: 'Brand Library', description: 'Manage keywords, question sets, and competitors around each brand.', }, + brandQuestionCreate: { + title: 'New question', + description: 'Create brand questions with the expansion tool, AI expansion, or manual input.', + }, tracking: { title: 'Data Details', description: @@ -1231,15 +1235,19 @@ const enUS = { }, brands: { eyebrow: 'Brand Library', - title: 'Brand Library', - description: 'Manage keywords, question sets, and competitor assets around each brand.', + title: 'Brand Management', + description: 'Manage question sets and competitor assets around each brand.', railTitle: 'Brands', - selectBrand: 'Pick a brand to continue managing keywords and question sets.', + selectBrand: 'Pick a brand to continue managing question sets.', newBrand: 'New brand', editBrand: 'Edit brand', deleteBrand: 'Delete brand', + guide: { + title: 'Brand management guide', + body: 'Manage brands, question sets, and competitor libraries for AI platform monitoring and GEO analysis.', + }, tabs: { - keywords: 'Keywords', + questions: 'Question set', competitors: 'Competitors', }, quota: { @@ -1249,12 +1257,13 @@ const enUS = { brandsHint: 'Free defaults to 1 and paid defaults to 2.', keywords: 'Total keywords', keywordsHint: 'The total number of keywords this account can bind.', - questions: 'Questions per keyword', - questionsHint: 'The maximum number of questions allowed under one keyword.', + questions: 'Account question quota', + questionsHint: 'All brands share this account question quota.', }, meta: { keywordCount: '{count} keywords', questionCount: '{count} questions', + competitorCount: '{count} competitors', brandKeywordCount: '{count} keywords under this brand', globalKeywordUsage: 'Total keywords used {used} / {total}', keywordQuestionUsage: 'Questions {used} / {total}', @@ -1266,6 +1275,7 @@ const enUS = { versions: 'Version history', }, actions: { + addBrand: 'Add brand', newKeyword: 'New keyword', newQuestion: 'New question', newCompetitor: 'New competitor', @@ -1273,8 +1283,10 @@ const enUS = { }, empty: { brands: 'No brand data yet. Create your first brand to start.', - questions: 'No questions under this keyword yet.', + questions: 'No questions under this brand yet.', competitors: 'No competitors under this brand yet.', + brandDescription: 'No brand description yet', + competitorDescription: 'No competitor description yet', }, form: { brandName: 'Brand name', @@ -1305,7 +1317,89 @@ const enUS = { chooseKeyword: 'Please choose a keyword first.', brandLimitReached: 'Your current plan allows up to {limit} brand companies.', keywordLimitReached: 'This account allows up to {limit} keywords.', - questionLimitReached: 'This keyword allows up to {limit} questions.', + questionLimitReached: 'This account allows up to {limit} questions.', + }, + questions: { + create: 'New question', + createSingle: 'Add single question', + expand: 'Bulk expand questions', + usage: 'Questions {used} / {total}', + remaining: '{count} remaining', + generate: 'Generate candidates', + emptyCandidates: 'No candidates yet. Generate or paste questions above.', + table: { + question: 'Question set', + }, + modal: { + title: 'New question', + currentBrand: 'Current brand', + methods: { + single: 'Single', + combination: 'Expansion tool', + aiDistill: 'AI expansion', + manualBatch: 'Manual batch', + }, + preview: 'Candidate preview', + summary: + '{total} candidates · {selected} selected · {tooShort} too short · {duplicate} duplicates', + save: 'Save questions ({count})', + }, + combination: { + region: '1. Region', + prefix: '2. Prefix', + core: '3. Core (required)', + industry: '4. Industry (required)', + suffix: '5. Suffix', + defaultWord: 'Default', + estimate: '{count} candidates estimated', + coreRequired: 'Core is required', + industryRequired: 'Industry is required', + }, + ai: { + note: 'AI expansion consumes 1 AI point. Failed generations are refunded automatically.', + seedTopic: 'Seed topic', + generate: 'Generate with AI', + }, + batch: { + label: 'One question per line', + }, + badges: { + duplicate: 'Duplicate', + tooShort: 'Too short', + }, + messages: { + partialSaved: 'Saved {created}; skipped {skipped}.', + limitReached: 'This account allows up to {limit} questions shared across all brands.', + truncated: 'Candidates exceeded the limit and were truncated to 500.', + cacheHit: 'Used the most recent AI expansion result.', + aiCharged: 'This AI expansion consumed {count} AI point.', + }, + errors: { + emptyInput: 'Enter question content first.', + 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', + subtitle: 'Generate candidates, review them, then save them to this brand question set.', + steps: { + configure: 'Configure', + preview: 'Review candidates', + }, + methodTitle: 'Choose a creation method', + methodHint: + 'Candidates remain temporary until saved, so you can generate, edit, and select first.', + methods: { + combination: 'Expand candidates from the column-based word tool.', + ai: 'Let AI generate candidates around a seed topic.', + batch: 'Paste questions from a sheet or document.', + }, + expansionHint: + 'One word per line. Empty optional columns are skipped; core and industry are required.', + batchHint: 'One question per line. You can edit and select each one before saving.', + }, }, }, custom: { diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index a31e05e..fa4be21 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -204,6 +204,10 @@ const zhCN = { title: "品牌词库", description: "围绕品牌维护关键词、问题集和竞品信息。", }, + brandQuestionCreate: { + title: "新建问题", + description: "用拓词工具、AI 扩展或手动录入创建品牌问题集。", + }, tracking: { title: "数据详情", description: "围绕插件采样快照查看品牌表现、平台矩阵、高频问题和引用归因。", @@ -1163,15 +1167,20 @@ const zhCN = { }, }, brands: { - title: "品牌词库", - description: "通过品牌名称和品牌相关信息维护关键词、问题集和竞品资产。", + eyebrow: "Brand Management", + title: "品牌管理", + description: "通过品牌名称和品牌相关信息维护问题集和竞品资产。", railTitle: "品牌库", - selectBrand: "选择品牌后即可继续维护关键词和问题集。", + selectBrand: "选择品牌后即可继续维护问题集。", newBrand: "新建品牌", editBrand: "编辑品牌", deleteBrand: "删除品牌", + guide: { + title: "品牌管理说明", + body: "管理品牌、问题集和竞品库,用于后续 AI 平台监测与 GEO 分析。", + }, tabs: { - keywords: "关键词", + questions: "问题集", competitors: "竞品库", }, quota: { @@ -1181,12 +1190,13 @@ const zhCN = { brandsHint: "免费版默认 1 个,付费版默认 2 个。", keywords: "关键词总量", keywordsHint: "当前用户可绑定的关键词总数。", - questions: "每个关键词问题数", - questionsHint: "单个关键词下最多可维护的问题数。", + questions: "账号问题额度", + questionsHint: "所有品牌共用当前账号的问题额度。", }, meta: { keywordCount: "{count} 个关键词", questionCount: "{count} 个问题", + competitorCount: "{count} 个竞品", brandKeywordCount: "当前品牌已绑定 {count} 个关键词", globalKeywordUsage: "总关键词已用 {used} / {total}", keywordQuestionUsage: "问题 {used} / {total}", @@ -1198,6 +1208,7 @@ const zhCN = { versions: "版本记录", }, actions: { + addBrand: "添加品牌", newKeyword: "新建关键词", newQuestion: "新建问题", newCompetitor: "新建竞品", @@ -1205,8 +1216,10 @@ const zhCN = { }, empty: { brands: "还没有品牌数据,先创建一个品牌。", - questions: "当前关键词下暂无问题集。", + questions: "当前品牌下暂无问题。", competitors: "当前品牌下暂无竞品数据。", + brandDescription: "暂无品牌描述", + competitorDescription: "暂无竞品描述", }, form: { brandName: "品牌名称", @@ -1237,7 +1250,85 @@ const zhCN = { chooseKeyword: "请先选择关键词", brandLimitReached: "当前套餐最多可绑定 {limit} 个品牌公司", keywordLimitReached: "当前账号最多可绑定 {limit} 个关键词", - questionLimitReached: "当前关键词下最多可维护 {limit} 个问题", + questionLimitReached: "当前账号最多可维护 {limit} 个问题", + }, + questions: { + create: "新建问题", + createSingle: "新增单条问题", + expand: "批量扩展问题", + usage: "问题 {used} / {total}", + remaining: "还可保存 {count} 条", + generate: "生成候选", + emptyCandidates: "还没有候选,先从上方生成或粘贴问题。", + table: { + question: "问题集", + }, + modal: { + title: "新建问题", + currentBrand: "当前品牌", + methods: { + single: "单条新增", + combination: "拓词工具", + aiDistill: "AI 扩展", + manualBatch: "手动批量", + }, + preview: "候选预览", + summary: "共 {total} 条候选 · 已选择 {selected} 条 · 过短 {tooShort} 条 · 重复 {duplicate} 条", + save: "保存问题 ({count})", + }, + combination: { + region: "1.地域词", + prefix: "2.前缀词", + core: "3.核心词(必填)", + industry: "4.行业词(必填)", + suffix: "5.后缀词", + defaultWord: "默认词", + estimate: "预计生成 {count} 条候选", + coreRequired: "核心不能为空", + industryRequired: "行业不能为空", + }, + ai: { + note: "AI 扩展会消耗 1 个 AI 点;生成失败时系统会自动退还。", + seedTopic: "扩展主题", + generate: "AI 生成候选", + }, + batch: { + label: "每行一个问题", + }, + badges: { + duplicate: "重复", + tooShort: "过短", + }, + messages: { + partialSaved: "已保存 {created} 条,跳过 {skipped} 条", + limitReached: "当前账号最多可维护 {limit} 个问题,所有品牌共用该额度", + truncated: "候选超过上限,已截断为 500 条。", + cacheHit: "已使用最近一次 AI 扩展结果。", + aiCharged: "本次 AI 扩展已消耗 {count} 个 AI 点。", + }, + errors: { + emptyInput: "请先输入问题内容", + noSelection: "请至少选择 1 条候选", + noValidQuestions: "没有可保存的问题,请调整候选后重试", + invalidCandidate: "该候选暂不可选择,请先处理过短、重复或非问题文本", + }, + page: { + title: "新建问题", + subtitle: "按向导生成候选,确认后一次性保存到当前品牌的问题集。", + steps: { + configure: "配置生成方式", + preview: "确认候选", + }, + methodTitle: "选择创建方式", + methodHint: "候选保存前都是临时内容,你可以先生成、再筛选和编辑。", + methods: { + combination: "按图示词列批量拓展问题候选。", + ai: "让 AI 围绕主题生成问题候选。", + batch: "从表格或文档中批量粘贴问题。", + }, + expansionHint: "每行一个词;空列会按默认词跳过,核心词和行业词必填。", + batchHint: "每行一个问题,保存前可以逐条编辑和勾选。", + }, }, }, custom: { diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 7cb8ddb..181e42d 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -72,6 +72,8 @@ import type { ListDesktopPublishTasksParams, LoginRequest, LoginResponse, + MaterializeQuestionsRequest, + MaterializeQuestionsResult, MediaPlatform, MonitoringCitationSummaryResponse, MonitoringCollectNowResponse, @@ -89,6 +91,9 @@ import type { PublishKolPromptRequest, PublishRecord, Question, + QuestionCandidateResult, + QuestionCombinationRequest, + QuestionDistillRequest, QuestionRequest, QuotaSummary, RecentArticle, @@ -122,6 +127,8 @@ import type { UpdateQuestionRequest, UserInfo, WorkspaceOverview, + ClassifyQuestionsRequest, + ClassifiedQuestion, } from '@geo/shared-types' import { @@ -1036,6 +1043,30 @@ export const brandsApi = { params: keywordId ? { keyword_id: keywordId } : undefined, }) }, + previewQuestionCombination(brandId: number, payload: QuestionCombinationRequest) { + return apiClient.post( + `/api/tenant/brands/${brandId}/questions/combination-preview`, + payload, + ) + }, + distillQuestions(brandId: number, payload: QuestionDistillRequest) { + return apiClient.post( + `/api/tenant/brands/${brandId}/questions/ai-distill`, + payload, + ) + }, + classifyQuestions(brandId: number, payload: ClassifyQuestionsRequest) { + return apiClient.post( + `/api/tenant/brands/${brandId}/questions/classify-metadata`, + payload, + ) + }, + materializeQuestions(brandId: number, payload: MaterializeQuestionsRequest) { + return apiClient.post( + `/api/tenant/brands/${brandId}/questions/materialize`, + payload, + ) + }, createQuestion(brandId: number, payload: QuestionRequest) { return apiClient.post( `/api/tenant/brands/${brandId}/questions`, diff --git a/apps/admin-web/src/lib/errors.ts b/apps/admin-web/src/lib/errors.ts index 9020080..d4817f0 100644 --- a/apps/admin-web/src/lib/errors.ts +++ b/apps/admin-web/src/lib/errors.ts @@ -67,7 +67,13 @@ const errorMessageMap: Record = { brand_limit_reached: '品牌公司数量已达当前套餐上限', keyword_exists: '关键词已存在', keyword_limit_reached: '关键词数量已达当前套餐上限', - question_limit_reached: '当前关键词下的问题数量已达上限', + question_exists: '该品牌下已存在相同问题', + no_valid_questions: '没有可保存的问题,请调整候选后重试', + invalid_enum: '请求枚举值不合法', + llm_timeout: 'AI 扩展超时,AI 点已退还', + llm_invalid_output: 'AI 输出无法解析,AI 点已退还', + materialize_failed: '保存问题失败,请稍后重试', + question_limit_reached: '当前账号问题数量已达上限', knowledge_text_name_too_long: '文本名称不能超过 20 个字', brand_not_found: '品牌不存在或已删除', keyword_not_found: '关键词不存在或已删除', diff --git a/apps/admin-web/src/router/index.ts b/apps/admin-web/src/router/index.ts index 57a5038..ec815d3 100644 --- a/apps/admin-web/src/router/index.ts +++ b/apps/admin-web/src/router/index.ts @@ -145,6 +145,16 @@ const router = createRouter({ navKey: '/brands', }, }, + { + path: 'brands/:brandId/questions/create', + name: 'brand-question-create', + component: () => import('@/views/BrandQuestionCreateView.vue'), + meta: { + titleKey: 'route.brandQuestionCreate.title', + descriptionKey: 'route.brandQuestionCreate.description', + navKey: '/brands', + }, + }, { path: 'tracking', name: 'tracking', diff --git a/apps/admin-web/src/views/BrandQuestionCreateView.vue b/apps/admin-web/src/views/BrandQuestionCreateView.vue new file mode 100644 index 0000000..bec5e1d --- /dev/null +++ b/apps/admin-web/src/views/BrandQuestionCreateView.vue @@ -0,0 +1,1338 @@ + + + + + diff --git a/apps/admin-web/src/views/BrandsView.vue b/apps/admin-web/src/views/BrandsView.vue index b29d3a2..895aaea 100644 --- a/apps/admin-web/src/views/BrandsView.vue +++ b/apps/admin-web/src/views/BrandsView.vue @@ -1,11 +1,23 @@