feat: require brand description in create and update operations, add validation messages
Frontend CI / Frontend (push) Successful in 4m25s
Backend CI / Backend (push) Successful in 15m23s

This commit is contained in:
2026-06-09 14:09:05 +08:00
parent a6d203a300
commit 41f5623791
9 changed files with 148 additions and 25 deletions
@@ -325,6 +325,7 @@ const enUS = {
}, },
messages: { messages: {
nameRequired: 'Enter a brand or company name.', 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.', questionRequired: 'Select at least one question, or skip and add them manually later.',
aiCharged: 'This generation consumed {count} AI point.', aiCharged: 'This generation consumed {count} AI point.',
}, },
@@ -1456,6 +1457,7 @@ const enUS = {
deleteCompetitor: 'Competitor deleted.', deleteCompetitor: 'Competitor deleted.',
chooseBrand: 'Please choose a brand first.', chooseBrand: 'Please choose a brand first.',
chooseKeyword: 'Please choose a keyword first.', chooseKeyword: 'Please choose a keyword first.',
brandDescriptionRequired: 'Enter a brand description.',
brandLimitReached: 'Your current plan allows up to {limit} brand companies.', brandLimitReached: 'Your current plan allows up to {limit} brand companies.',
keywordLimitReached: 'This account allows up to {limit} keywords.', keywordLimitReached: 'This account allows up to {limit} keywords.',
questionLimitReached: 'This account allows up to {limit} search terms.', questionLimitReached: 'This account allows up to {limit} search terms.',
@@ -1545,6 +1547,7 @@ const enUS = {
aiSeedTopicTooShortWithMore: 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".', '{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.', 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.', noValidQuestions: 'No valid search terms can be saved. Adjust candidates and try again.',
}, },
page: { page: {
@@ -311,6 +311,7 @@ const zhCN = {
}, },
messages: { messages: {
nameRequired: '请输入品牌或公司名', nameRequired: '请输入品牌或公司名',
descriptionRequired: '请输入品牌描述',
questionRequired: '请至少选择 1 个问题,或跳过后手动添加', questionRequired: '请至少选择 1 个问题,或跳过后手动添加',
aiCharged: '本次生成已消耗 {count} 个 AI 点', aiCharged: '本次生成已消耗 {count} 个 AI 点',
}, },
@@ -1387,6 +1388,7 @@ const zhCN = {
deleteCompetitor: '竞品已删除', deleteCompetitor: '竞品已删除',
chooseBrand: '请先选择品牌', chooseBrand: '请先选择品牌',
chooseKeyword: '请先选择关键词', chooseKeyword: '请先选择关键词',
brandDescriptionRequired: '请输入品牌描述',
brandLimitReached: '当前套餐最多可绑定 {limit} 个品牌公司', brandLimitReached: '当前套餐最多可绑定 {limit} 个品牌公司',
keywordLimitReached: '当前账号最多可绑定 {limit} 个关键词', keywordLimitReached: '当前账号最多可绑定 {limit} 个关键词',
questionLimitReached: '当前账号最多可维护 {limit} 个搜索词', questionLimitReached: '当前账号最多可维护 {limit} 个搜索词',
@@ -1473,6 +1475,7 @@ const zhCN = {
aiSeedTopicTooShortWithMore: aiSeedTopicTooShortWithMore:
'{terms} 等 {count} 个搜索词太短,请输入至少 2 个字的用户搜索词,例如“床垫推荐”或“儿童床怎么选”。', '{terms} 等 {count} 个搜索词太短,请输入至少 2 个字的用户搜索词,例如“床垫推荐”或“儿童床怎么选”。',
noSelection: '请至少选择 1 条候选', noSelection: '请至少选择 1 条候选',
duplicateExisting: '关键词库已有,请添加其他关键词',
noValidQuestions: '没有可保存的搜索词,请调整候选后重试', noValidQuestions: '没有可保存的搜索词,请调整候选后重试',
}, },
page: { page: {
+7 -3
View File
@@ -195,7 +195,7 @@ const createBrandMutation = useMutation({
brandsApi.create({ brandsApi.create({
name: brandForm.name.trim(), name: brandForm.name.trim(),
website: brandForm.website.trim() || null, website: brandForm.website.trim() || null,
description: brandForm.description.trim() || null, description: brandForm.description.trim(),
}), }),
onSuccess: async (brand) => { onSuccess: async (brand) => {
message.success(t('brands.messages.createBrand')) message.success(t('brands.messages.createBrand'))
@@ -212,6 +212,10 @@ async function submitBrand(): Promise<void> {
if (!brandForm.name.trim()) { if (!brandForm.name.trim()) {
return return
} }
if (!brandForm.description.trim()) {
message.warning(t('brands.messages.brandDescriptionRequired'))
return
}
await createBrandMutation.mutateAsync() await createBrandMutation.mutateAsync()
} }
@@ -868,13 +872,13 @@ onBeforeUnmount(() => {
@ok="submitBrand" @ok="submitBrand"
> >
<a-form layout="vertical"> <a-form layout="vertical">
<a-form-item :label="t('brands.form.brandName')"> <a-form-item :label="t('brands.form.brandName')" required>
<a-input v-model:value="brandForm.name" /> <a-input v-model:value="brandForm.name" />
</a-form-item> </a-form-item>
<a-form-item :label="t('brands.form.brandWebsite')"> <a-form-item :label="t('brands.form.brandWebsite')">
<a-input v-model:value="brandForm.website" /> <a-input v-model:value="brandForm.website" />
</a-form-item> </a-form-item>
<a-form-item :label="t('brands.form.brandDescription')"> <a-form-item :label="t('brands.form.brandDescription')" required>
<a-textarea v-model:value="brandForm.description" :rows="4" /> <a-textarea v-model:value="brandForm.description" :rows="4" />
</a-form-item> </a-form-item>
</a-form> </a-form>
+48 -2
View File
@@ -13,6 +13,13 @@ const authSessionErrorMessages = new Set([
'token_revoked', 'token_revoked',
]) ])
const duplicateQuestionSkipReasons = new Set(['duplicate', 'duplicate_concurrent'])
interface SkipReasonDetail {
text?: string
reason?: string
}
const errorMessageMap: Record<string, string> = { const errorMessageMap: Record<string, string> = {
invalid_credentials: '邮箱或密码错误', invalid_credentials: '邮箱或密码错误',
invalid_old_password: '原密码错误', invalid_old_password: '原密码错误',
@@ -114,11 +121,12 @@ const errorMessageMap: Record<string, string> = {
invalid_payload: '请求参数不合法', invalid_payload: '请求参数不合法',
update_failed: '保存文章失败', update_failed: '保存文章失败',
brand_exists: '品牌名称已存在', brand_exists: '品牌名称已存在',
brand_description_required: '请输入品牌描述',
brand_limit_reached: '品牌公司数量已达当前套餐上限', brand_limit_reached: '品牌公司数量已达当前套餐上限',
keyword_exists: '关键词已存在', keyword_exists: '关键词已存在',
keyword_limit_reached: '关键词数量已达当前套餐上限', keyword_limit_reached: '关键词数量已达当前套餐上限',
question_exists: '该品牌下已存在相同问题', question_exists: '该品牌下已存在相同搜索词',
no_valid_questions: '没有可保存的问题,请调整候选后重试', no_valid_questions: '没有可保存的搜索词,请调整候选后重试',
invalid_enum: '请求枚举值不合法', invalid_enum: '请求枚举值不合法',
llm_timeout: 'AI 扩展超时,AI 点已退还', llm_timeout: 'AI 扩展超时,AI 点已退还',
llm_invalid_output: 'AI 输出无法解析,AI 点已退还', llm_invalid_output: 'AI 输出无法解析,AI 点已退还',
@@ -367,3 +375,41 @@ export function formatError(error: unknown): string {
return '发生未知错误' 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 []
}
}
@@ -17,7 +17,11 @@ import { useRouter } from 'vue-router'
import AiWaitingModal from '@/components/AiWaitingModal.vue' import AiWaitingModal from '@/components/AiWaitingModal.vue'
import { brandsApi } from '@/lib/api' import { brandsApi } from '@/lib/api'
import { formatError } from '@/lib/errors' import {
formatError,
getQuestionMaterializeErrorMessage,
isDuplicateQuestionSkipReason,
} from '@/lib/errors'
import { useCompanyStore } from '@/stores/company' import { useCompanyStore } from '@/stores/company'
const DEFAULT_SELECTED_QUESTION_COUNT = 5 const DEFAULT_SELECTED_QUESTION_COUNT = 5
@@ -290,6 +294,10 @@ async function submitBrand(): Promise<void> {
message.warning(t('onboardingBrand.messages.nameRequired')) message.warning(t('onboardingBrand.messages.nameRequired'))
return return
} }
if (!description) {
message.warning(t('onboardingBrand.messages.descriptionRequired'))
return
}
if (brandLimitReached.value) { if (brandLimitReached.value) {
message.warning( message.warning(
t('brands.messages.brandLimitReached', { t('brands.messages.brandLimitReached', {
@@ -303,7 +311,7 @@ async function submitBrand(): Promise<void> {
const brand = await createBrandMutation.mutateAsync({ const brand = await createBrandMutation.mutateAsync({
name, name,
website: website || null, website: website || null,
description: description || null, description,
}) })
createdBrand.value = brand createdBrand.value = brand
brandModalOpen.value = false brandModalOpen.value = false
@@ -365,7 +373,14 @@ async function saveSelectedQuestions(): Promise<void> {
questions: selectedCandidates.value.map((item) => item.text.trim()).filter(Boolean), questions: selectedCandidates.value.map((item) => item.text.trim()).filter(Boolean),
}) })
if (result.created_questions <= 0) { 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 return
} }
message.success( message.success(
@@ -377,8 +392,12 @@ async function saveSelectedQuestions(): Promise<void> {
questionModalOpen.value = false questionModalOpen.value = false
await enterWorkspace(brand) await enterWorkspace(brand)
} catch (error) { } catch (error) {
if (error instanceof ApiClientError && error.message === 'no_valid_questions') { const materializeMessage = getQuestionMaterializeErrorMessage(error, {
message.warning(t('brands.questions.errors.noValidQuestions')) duplicate: t('brands.questions.errors.duplicateExisting'),
noValid: t('brands.questions.errors.noValidQuestions'),
})
if (materializeMessage) {
message.warning(materializeMessage)
return return
} }
message.error(formatError(error)) message.error(formatError(error))
@@ -463,7 +482,7 @@ async function saveSelectedQuestions(): Promise<void> {
<template #prefix><GlobalOutlined /></template> <template #prefix><GlobalOutlined /></template>
</a-input> </a-input>
</a-form-item> </a-form-item>
<a-form-item :label="t('onboardingBrand.form.description')"> <a-form-item :label="t('onboardingBrand.form.description')" required>
<a-textarea <a-textarea
v-model:value="brandForm.description" v-model:value="brandForm.description"
:rows="4" :rows="4"
@@ -17,7 +17,11 @@ import { useRoute, useRouter } from 'vue-router'
import AiWaitingModal from '@/components/AiWaitingModal.vue' import AiWaitingModal from '@/components/AiWaitingModal.vue'
import { brandsApi } from '@/lib/api' import { brandsApi } from '@/lib/api'
import { formatError } from '@/lib/errors' import {
formatError,
getQuestionMaterializeErrorMessage,
isDuplicateQuestionSkipReason,
} from '@/lib/errors'
type CreateMode = 'combination' | 'ai' | 'batch' type CreateMode = 'combination' | 'ai' | 'batch'
@@ -155,6 +159,14 @@ const existingQuestionSet = computed(() => {
}) })
const selectedCandidates = computed(() => candidateRows.value.filter((item) => item.selected)) 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 candidateStats = computed(() => {
const total = candidateRows.value.length const total = candidateRows.value.length
const selected = selectedCandidates.value.length const selected = selectedCandidates.value.length
@@ -321,11 +333,19 @@ const materializeMutation = useMutation({
}) })
return 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) => { onError: (error) => {
if (error instanceof ApiClientError && error.message === 'no_valid_questions') { const materializeMessage = getQuestionMaterializeErrorMessage(error, {
pageNotice.value = t('brands.questions.errors.noValidQuestions') duplicate: t('brands.questions.errors.duplicateExisting'),
noValid: t('brands.questions.errors.noValidQuestions'),
})
if (materializeMessage) {
pageNotice.value = materializeMessage
return return
} }
pageNotice.value = formatError(error) pageNotice.value = formatError(error)
@@ -786,6 +806,10 @@ async function saveCandidates(): Promise<void> {
pageNotice.value = t('brands.questions.errors.noSelection') pageNotice.value = t('brands.questions.errors.noSelection')
return return
} }
if (selectedCandidatesAllExistInLibrary.value) {
pageNotice.value = t('brands.questions.errors.duplicateExisting')
return
}
await materializeMutation.mutateAsync() await materializeMutation.mutateAsync()
} }
+27 -9
View File
@@ -5,7 +5,6 @@ import {
PlusOutlined, PlusOutlined,
ThunderboltOutlined, ThunderboltOutlined,
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import { ApiClientError } from '@geo/http-client'
import type { Brand, BrandLibrarySummary, Competitor, Question } from '@geo/shared-types' import type { Brand, BrandLibrarySummary, Competitor, Question } from '@geo/shared-types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { TableColumnsType } from 'ant-design-vue' import type { TableColumnsType } from 'ant-design-vue'
@@ -16,7 +15,11 @@ import { useRoute, useRouter } from 'vue-router'
import { brandsApi } from '@/lib/api' import { brandsApi } from '@/lib/api'
import { formatDateTime } from '@/lib/display' import { formatDateTime } from '@/lib/display'
import { formatError } from '@/lib/errors' import {
formatError,
getQuestionMaterializeErrorMessage,
isDuplicateQuestionSkipReason,
} from '@/lib/errors'
import { useCompanyStore } from '@/stores/company' import { useCompanyStore } from '@/stores/company'
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -142,7 +145,7 @@ const brandMutations = {
brandsApi.create({ brandsApi.create({
name: brandForm.name.trim(), name: brandForm.name.trim(),
website: brandForm.website.trim() || null, website: brandForm.website.trim() || null,
description: brandForm.description.trim() || null, description: brandForm.description.trim(),
}), }),
onSuccess: async (brand) => { onSuccess: async (brand) => {
message.success(t('brands.messages.createBrand')) message.success(t('brands.messages.createBrand'))
@@ -158,7 +161,7 @@ const brandMutations = {
brandsApi.update(editingBrandId.value as number, { brandsApi.update(editingBrandId.value as number, {
name: brandForm.name.trim(), name: brandForm.name.trim(),
website: brandForm.website.trim() || null, website: brandForm.website.trim() || null,
description: brandForm.description.trim() || null, description: brandForm.description.trim(),
}), }),
onSuccess: async () => { onSuccess: async () => {
message.success(t('brands.messages.updateBrand')) message.success(t('brands.messages.updateBrand'))
@@ -187,7 +190,14 @@ const questionMutations = {
}), }),
onSuccess: async (result) => { onSuccess: async (result) => {
if (result.created_questions <= 0) { 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 return
} }
message.success(t('brands.messages.createQuestion')) message.success(t('brands.messages.createQuestion'))
@@ -196,8 +206,12 @@ const questionMutations = {
await invalidateBrandQueries() await invalidateBrandQueries()
}, },
onError: (error) => { onError: (error) => {
if (error instanceof ApiClientError && error.message === 'no_valid_questions') { const materializeMessage = getQuestionMaterializeErrorMessage(error, {
message.warning(t('brands.questions.errors.noValidQuestions')) duplicate: t('brands.questions.errors.duplicateExisting'),
noValid: t('brands.questions.errors.noValidQuestions'),
})
if (materializeMessage) {
message.warning(materializeMessage)
return return
} }
message.error(formatError(error)) message.error(formatError(error))
@@ -390,6 +404,10 @@ async function submitBrand(): Promise<void> {
if (!brandForm.name.trim()) { if (!brandForm.name.trim()) {
return return
} }
if (!brandForm.description.trim()) {
message.warning(t('brands.messages.brandDescriptionRequired'))
return
}
if (editingBrandId.value) { if (editingBrandId.value) {
await brandMutations.update.mutateAsync() await brandMutations.update.mutateAsync()
return return
@@ -628,13 +646,13 @@ async function invalidateBrandQueries(): Promise<void> {
@ok="submitBrand" @ok="submitBrand"
> >
<a-form layout="vertical"> <a-form layout="vertical">
<a-form-item :label="t('brands.form.brandName')"> <a-form-item :label="t('brands.form.brandName')" required>
<a-input v-model:value="brandForm.name" /> <a-input v-model:value="brandForm.name" />
</a-form-item> </a-form-item>
<a-form-item :label="t('brands.form.brandWebsite')"> <a-form-item :label="t('brands.form.brandWebsite')">
<a-input v-model:value="brandForm.website" /> <a-input v-model:value="brandForm.website" />
</a-form-item> </a-form-item>
<a-form-item :label="t('brands.form.brandDescription')"> <a-form-item :label="t('brands.form.brandDescription')" required>
<a-textarea v-model:value="brandForm.description" :rows="4" /> <a-textarea v-model:value="brandForm.description" :rows="4" />
</a-form-item> </a-form-item>
</a-form> </a-form>
+1 -1
View File
@@ -1670,7 +1670,7 @@ export interface PublisherPublishResponse {
export interface BrandRequest { export interface BrandRequest {
name: string name: string
website?: string | null website?: string | null
description?: string | null description: string
} }
export interface Brand { export interface Brand {
@@ -123,6 +123,9 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp
if req.Name == "" { if req.Name == "" {
return nil, response.ErrBadRequest(40001, "invalid_params", "name is required") 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) summary, err := s.loadBrandLibrarySummary(ctx, actor.TenantID)
if err != nil { if err != nil {
@@ -218,6 +221,9 @@ func (s *BrandService) Update(ctx context.Context, id int64, req BrandRequest) e
if req.Name == "" { if req.Name == "" {
return response.ErrBadRequest(40001, "invalid_params", "name is required") 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, ` tag, err := s.pool.Exec(ctx, `
UPDATE brands SET name = $1, website = $2, description = $3, updated_at = NOW() UPDATE brands SET name = $1, website = $2, description = $3, updated_at = NOW()
WHERE id = $4 AND tenant_id = $5 AND deleted_at IS NULL WHERE id = $4 AND tenant_id = $5 AND deleted_at IS NULL