diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index d36fb20..99e51c2 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -325,6 +325,7 @@ const enUS = { }, messages: { nameRequired: 'Enter a brand or company name.', + descriptionRequired: 'Enter a brand description.', questionRequired: 'Select at least one question, or skip and add them manually later.', aiCharged: 'This generation consumed {count} AI point.', }, @@ -1456,6 +1457,7 @@ const enUS = { deleteCompetitor: 'Competitor deleted.', chooseBrand: 'Please choose a brand first.', chooseKeyword: 'Please choose a keyword first.', + brandDescriptionRequired: 'Enter a brand description.', brandLimitReached: 'Your current plan allows up to {limit} brand companies.', keywordLimitReached: 'This account allows up to {limit} keywords.', questionLimitReached: 'This account allows up to {limit} search terms.', @@ -1545,6 +1547,7 @@ const enUS = { aiSeedTopicTooShortWithMore: '{terms} and {count} more search terms are too short. Enter user search terms with at least 2 characters, such as "mattress recommendation" or "how to choose a kids bed".', noSelection: 'Select at least one candidate.', + duplicateExisting: 'This keyword library already has this term. Add another keyword.', noValidQuestions: 'No valid search terms can be saved. Adjust candidates and try again.', }, page: { diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index 521df88..a518095 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -311,6 +311,7 @@ const zhCN = { }, messages: { nameRequired: '请输入品牌或公司名', + descriptionRequired: '请输入品牌描述', questionRequired: '请至少选择 1 个问题,或跳过后手动添加', aiCharged: '本次生成已消耗 {count} 个 AI 点', }, @@ -1387,6 +1388,7 @@ const zhCN = { deleteCompetitor: '竞品已删除', chooseBrand: '请先选择品牌', chooseKeyword: '请先选择关键词', + brandDescriptionRequired: '请输入品牌描述', brandLimitReached: '当前套餐最多可绑定 {limit} 个品牌公司', keywordLimitReached: '当前账号最多可绑定 {limit} 个关键词', questionLimitReached: '当前账号最多可维护 {limit} 个搜索词', @@ -1473,6 +1475,7 @@ const zhCN = { aiSeedTopicTooShortWithMore: '{terms} 等 {count} 个搜索词太短,请输入至少 2 个字的用户搜索词,例如“床垫推荐”或“儿童床怎么选”。', noSelection: '请至少选择 1 条候选', + duplicateExisting: '关键词库已有,请添加其他关键词', noValidQuestions: '没有可保存的搜索词,请调整候选后重试', }, page: { diff --git a/apps/admin-web/src/layouts/AppShell.vue b/apps/admin-web/src/layouts/AppShell.vue index b524eb9..d4cb5ef 100644 --- a/apps/admin-web/src/layouts/AppShell.vue +++ b/apps/admin-web/src/layouts/AppShell.vue @@ -195,7 +195,7 @@ const createBrandMutation = useMutation({ brandsApi.create({ name: brandForm.name.trim(), website: brandForm.website.trim() || null, - description: brandForm.description.trim() || null, + description: brandForm.description.trim(), }), onSuccess: async (brand) => { message.success(t('brands.messages.createBrand')) @@ -212,6 +212,10 @@ async function submitBrand(): Promise { if (!brandForm.name.trim()) { return } + if (!brandForm.description.trim()) { + message.warning(t('brands.messages.brandDescriptionRequired')) + return + } await createBrandMutation.mutateAsync() } @@ -868,13 +872,13 @@ onBeforeUnmount(() => { @ok="submitBrand" > - + - + diff --git a/apps/admin-web/src/lib/errors.ts b/apps/admin-web/src/lib/errors.ts index 46d739f..d00c42e 100644 --- a/apps/admin-web/src/lib/errors.ts +++ b/apps/admin-web/src/lib/errors.ts @@ -13,6 +13,13 @@ const authSessionErrorMessages = new Set([ 'token_revoked', ]) +const duplicateQuestionSkipReasons = new Set(['duplicate', 'duplicate_concurrent']) + +interface SkipReasonDetail { + text?: string + reason?: string +} + const errorMessageMap: Record = { invalid_credentials: '邮箱或密码错误', invalid_old_password: '原密码错误', @@ -114,11 +121,12 @@ const errorMessageMap: Record = { invalid_payload: '请求参数不合法', update_failed: '保存文章失败', brand_exists: '品牌名称已存在', + brand_description_required: '请输入品牌描述', brand_limit_reached: '品牌公司数量已达当前套餐上限', keyword_exists: '关键词已存在', keyword_limit_reached: '关键词数量已达当前套餐上限', - question_exists: '该品牌下已存在相同问题', - no_valid_questions: '没有可保存的问题,请调整候选后重试', + question_exists: '该品牌下已存在相同搜索词', + no_valid_questions: '没有可保存的搜索词,请调整候选后重试', invalid_enum: '请求枚举值不合法', llm_timeout: 'AI 扩展超时,AI 点已退还', llm_invalid_output: 'AI 输出无法解析,AI 点已退还', @@ -367,3 +375,41 @@ export function formatError(error: unknown): string { return '发生未知错误' } + +export function getQuestionMaterializeErrorMessage( + error: unknown, + messages: { + duplicate: string + noValid: string + }, +): string | null { + if (!(error instanceof ApiClientError) || error.message !== 'no_valid_questions') { + return null + } + const reasons = parseSkipReasons(error.detail) + if (reasons.length > 0 && reasons.every((item) => isDuplicateQuestionSkipReason(item.reason))) { + return messages.duplicate + } + return messages.noValid +} + +export function isDuplicateQuestionSkipReason(reason?: string): boolean { + return duplicateQuestionSkipReasons.has(reason ?? '') +} + +function parseSkipReasons(detail?: string): SkipReasonDetail[] { + if (!detail) { + return [] + } + try { + const parsed = JSON.parse(detail) + if (!Array.isArray(parsed)) { + return [] + } + return parsed.filter((item): item is SkipReasonDetail => { + return item !== null && typeof item === 'object' && typeof item.reason === 'string' + }) + } catch { + return [] + } +} diff --git a/apps/admin-web/src/views/BrandOnboardingView.vue b/apps/admin-web/src/views/BrandOnboardingView.vue index 88819f0..a5c1247 100644 --- a/apps/admin-web/src/views/BrandOnboardingView.vue +++ b/apps/admin-web/src/views/BrandOnboardingView.vue @@ -17,7 +17,11 @@ import { useRouter } from 'vue-router' import AiWaitingModal from '@/components/AiWaitingModal.vue' import { brandsApi } from '@/lib/api' -import { formatError } from '@/lib/errors' +import { + formatError, + getQuestionMaterializeErrorMessage, + isDuplicateQuestionSkipReason, +} from '@/lib/errors' import { useCompanyStore } from '@/stores/company' const DEFAULT_SELECTED_QUESTION_COUNT = 5 @@ -290,6 +294,10 @@ async function submitBrand(): Promise { message.warning(t('onboardingBrand.messages.nameRequired')) return } + if (!description) { + message.warning(t('onboardingBrand.messages.descriptionRequired')) + return + } if (brandLimitReached.value) { message.warning( t('brands.messages.brandLimitReached', { @@ -303,7 +311,7 @@ async function submitBrand(): Promise { const brand = await createBrandMutation.mutateAsync({ name, website: website || null, - description: description || null, + description, }) createdBrand.value = brand brandModalOpen.value = false @@ -365,7 +373,14 @@ async function saveSelectedQuestions(): Promise { questions: selectedCandidates.value.map((item) => item.text.trim()).filter(Boolean), }) if (result.created_questions <= 0) { - message.warning(t('brands.questions.errors.noValidQuestions')) + const skippedAllDuplicates = + result.skipped_questions.length > 0 && + result.skipped_questions.every((item) => isDuplicateQuestionSkipReason(item.reason)) + message.warning( + skippedAllDuplicates + ? t('brands.questions.errors.duplicateExisting') + : t('brands.questions.errors.noValidQuestions'), + ) return } message.success( @@ -377,8 +392,12 @@ async function saveSelectedQuestions(): Promise { questionModalOpen.value = false await enterWorkspace(brand) } catch (error) { - if (error instanceof ApiClientError && error.message === 'no_valid_questions') { - message.warning(t('brands.questions.errors.noValidQuestions')) + const materializeMessage = getQuestionMaterializeErrorMessage(error, { + duplicate: t('brands.questions.errors.duplicateExisting'), + noValid: t('brands.questions.errors.noValidQuestions'), + }) + if (materializeMessage) { + message.warning(materializeMessage) return } message.error(formatError(error)) @@ -463,7 +482,7 @@ async function saveSelectedQuestions(): Promise { - + { }) const selectedCandidates = computed(() => candidateRows.value.filter((item) => item.selected)) +const selectedCandidatesAllExistInLibrary = computed(() => { + if (!selectedCandidates.value.length) { + return false + } + return selectedCandidates.value.every((item) => + existingQuestionSet.value.has(normalizeQuestionKey(item.text)), + ) +}) const candidateStats = computed(() => { const total = candidateRows.value.length const selected = selectedCandidates.value.length @@ -321,11 +333,19 @@ const materializeMutation = useMutation({ }) return } - pageNotice.value = t('brands.questions.errors.noValidQuestions') + pageNotice.value = + result.skipped_questions.length > 0 && + result.skipped_questions.every((item) => isDuplicateQuestionSkipReason(item.reason)) + ? t('brands.questions.errors.duplicateExisting') + : t('brands.questions.errors.noValidQuestions') }, onError: (error) => { - if (error instanceof ApiClientError && error.message === 'no_valid_questions') { - pageNotice.value = t('brands.questions.errors.noValidQuestions') + const materializeMessage = getQuestionMaterializeErrorMessage(error, { + duplicate: t('brands.questions.errors.duplicateExisting'), + noValid: t('brands.questions.errors.noValidQuestions'), + }) + if (materializeMessage) { + pageNotice.value = materializeMessage return } pageNotice.value = formatError(error) @@ -786,6 +806,10 @@ async function saveCandidates(): Promise { pageNotice.value = t('brands.questions.errors.noSelection') return } + if (selectedCandidatesAllExistInLibrary.value) { + pageNotice.value = t('brands.questions.errors.duplicateExisting') + return + } await materializeMutation.mutateAsync() } diff --git a/apps/admin-web/src/views/BrandsView.vue b/apps/admin-web/src/views/BrandsView.vue index 6793401..a4a62aa 100644 --- a/apps/admin-web/src/views/BrandsView.vue +++ b/apps/admin-web/src/views/BrandsView.vue @@ -5,7 +5,6 @@ import { PlusOutlined, ThunderboltOutlined, } from '@ant-design/icons-vue' -import { ApiClientError } from '@geo/http-client' import type { Brand, BrandLibrarySummary, Competitor, Question } from '@geo/shared-types' import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import type { TableColumnsType } from 'ant-design-vue' @@ -16,7 +15,11 @@ import { useRoute, useRouter } from 'vue-router' import { brandsApi } from '@/lib/api' import { formatDateTime } from '@/lib/display' -import { formatError } from '@/lib/errors' +import { + formatError, + getQuestionMaterializeErrorMessage, + isDuplicateQuestionSkipReason, +} from '@/lib/errors' import { useCompanyStore } from '@/stores/company' const queryClient = useQueryClient() @@ -142,7 +145,7 @@ const brandMutations = { brandsApi.create({ name: brandForm.name.trim(), website: brandForm.website.trim() || null, - description: brandForm.description.trim() || null, + description: brandForm.description.trim(), }), onSuccess: async (brand) => { message.success(t('brands.messages.createBrand')) @@ -158,7 +161,7 @@ const brandMutations = { brandsApi.update(editingBrandId.value as number, { name: brandForm.name.trim(), website: brandForm.website.trim() || null, - description: brandForm.description.trim() || null, + description: brandForm.description.trim(), }), onSuccess: async () => { message.success(t('brands.messages.updateBrand')) @@ -187,7 +190,14 @@ const questionMutations = { }), onSuccess: async (result) => { if (result.created_questions <= 0) { - message.warning(t('brands.questions.errors.noValidQuestions')) + const skippedAllDuplicates = + result.skipped_questions.length > 0 && + result.skipped_questions.every((item) => isDuplicateQuestionSkipReason(item.reason)) + message.warning( + skippedAllDuplicates + ? t('brands.questions.errors.duplicateExisting') + : t('brands.questions.errors.noValidQuestions'), + ) return } message.success(t('brands.messages.createQuestion')) @@ -196,8 +206,12 @@ const questionMutations = { await invalidateBrandQueries() }, onError: (error) => { - if (error instanceof ApiClientError && error.message === 'no_valid_questions') { - message.warning(t('brands.questions.errors.noValidQuestions')) + const materializeMessage = getQuestionMaterializeErrorMessage(error, { + duplicate: t('brands.questions.errors.duplicateExisting'), + noValid: t('brands.questions.errors.noValidQuestions'), + }) + if (materializeMessage) { + message.warning(materializeMessage) return } message.error(formatError(error)) @@ -390,6 +404,10 @@ async function submitBrand(): Promise { if (!brandForm.name.trim()) { return } + if (!brandForm.description.trim()) { + message.warning(t('brands.messages.brandDescriptionRequired')) + return + } if (editingBrandId.value) { await brandMutations.update.mutateAsync() return @@ -628,13 +646,13 @@ async function invalidateBrandQueries(): Promise { @ok="submitBrand" > - + - + diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index b556439..3fb3654 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -1670,7 +1670,7 @@ export interface PublisherPublishResponse { export interface BrandRequest { name: string website?: string | null - description?: string | null + description: string } export interface Brand { diff --git a/server/internal/tenant/app/brand_service.go b/server/internal/tenant/app/brand_service.go index acb973c..5d28b53 100644 --- a/server/internal/tenant/app/brand_service.go +++ b/server/internal/tenant/app/brand_service.go @@ -123,6 +123,9 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp if req.Name == "" { return nil, response.ErrBadRequest(40001, "invalid_params", "name is required") } + if req.Description == nil { + return nil, response.ErrBadRequest(40002, "brand_description_required", "description is required") + } summary, err := s.loadBrandLibrarySummary(ctx, actor.TenantID) if err != nil { @@ -218,6 +221,9 @@ func (s *BrandService) Update(ctx context.Context, id int64, req BrandRequest) e if req.Name == "" { return response.ErrBadRequest(40001, "invalid_params", "name is required") } + if req.Description == nil { + return response.ErrBadRequest(40002, "brand_description_required", "description is required") + } tag, err := s.pool.Exec(ctx, ` UPDATE brands SET name = $1, website = $2, description = $3, updated_at = NOW() WHERE id = $4 AND tenant_id = $5 AND deleted_at IS NULL