fix(brand-questions): surface seed topic min length as actionable error

Reject AI seed topics shorter than 2 characters on both client and server with
a dedicated invalid_seed_topic error code, and render an inline hint listing
the offending terms so users know exactly what to fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 10:18:28 +08:00
parent 7d8e82c69f
commit 2c4f7d6fcf
6 changed files with 112 additions and 2 deletions
@@ -30,6 +30,8 @@ interface CandidateRow extends QuestionCandidate {
selected: boolean
}
const AI_SEED_TOPIC_MIN_LENGTH = 2
const queryClient = useQueryClient()
const route = useRoute()
const router = useRouter()
@@ -44,6 +46,7 @@ const aiCandidateModalOpen = ref(false)
const aiCandidateProgress = ref(12)
const aiCandidateStageIndex = ref(0)
const aiWaitingKind = ref<'candidate' | 'fill'>('candidate')
const aiSeedTopicError = ref('')
let aiCandidateProgressTimer: number | null = null
let aiCandidateCloseTimer: number | null = null
@@ -269,7 +272,7 @@ const previewMutations = {
currentStep.value = 1
},
onError: (error) => {
pageNotice.value = formatError(error)
handleAiPreviewError(error)
},
}),
}
@@ -366,7 +369,14 @@ const normalizedAiSeedTopics = computed(() => {
return topics
})
const tooShortAiSeedTopics = computed(() =>
normalizedAiSeedTopics.value.filter(
(topic) => countSearchTermChars(topic) < AI_SEED_TOPIC_MIN_LENGTH,
),
)
function updateAiSeedTopics(value: string[]): void {
clearAiSeedTopicError()
aiForm.seed_topics.splice(0, aiForm.seed_topics.length, ...dedupeTopicParts(value))
}
@@ -506,6 +516,10 @@ function normalizeSearchTermText(value: string): string {
.replace(/\s+/g, '')
}
function countSearchTermChars(value: string): number {
return Array.from(normalizeSearchTermText(value)).length
}
function searchTermLooksValid(value: string): boolean {
const text = normalizeSearchTermText(value)
if (text.length < 4) {
@@ -565,12 +579,63 @@ function changeCreateMode(mode: CreateMode): void {
if (createMode.value === mode) {
return
}
clearAiSeedTopicError()
createMode.value = mode
currentStep.value = 0
candidateRows.value = []
pageNotice.value = ''
}
function setAiSeedTopicError(value: string): void {
aiSeedTopicError.value = value
pageNotice.value = value
}
function clearAiSeedTopicError(): void {
if (aiSeedTopicError.value && pageNotice.value === aiSeedTopicError.value) {
pageNotice.value = ''
}
aiSeedTopicError.value = ''
}
function formatQuotedTerms(terms: string[]): string {
return terms
.slice(0, 3)
.map((term) => `${term}`)
.join('、')
}
function buildAiSeedTopicTooShortMessage(terms: string[]): string {
const visibleTerms = formatQuotedTerms(terms)
const hiddenCount = Math.max(terms.length - 3, 0)
if (hiddenCount > 0) {
return t('brands.questions.errors.aiSeedTopicTooShortWithMore', {
terms: visibleTerms,
count: hiddenCount,
})
}
return t('brands.questions.errors.aiSeedTopicTooShort', {
terms: visibleTerms,
})
}
function isAiSeedTopicValidationError(error: unknown): boolean {
return (
error instanceof ApiClientError &&
(error.message === 'invalid_seed_topic' ||
(error.message === 'invalid_params' && /SeedTopic|seed_topic/.test(error.detail ?? '')))
)
}
function handleAiPreviewError(error: unknown): void {
const notice = formatError(error)
if (isAiSeedTopicValidationError(error)) {
setAiSeedTopicError(notice)
return
}
pageNotice.value = notice
}
function clearAiCandidateProgressTimer(): void {
if (aiCandidateProgressTimer === null) {
return
@@ -629,6 +694,7 @@ onBeforeUnmount(() => {
async function generateCandidates(): Promise<void> {
pageNotice.value = ''
clearAiSeedTopicError()
if (questionLimitReached.value) {
pageNotice.value = t('brands.questions.messages.limitReached', {
limit: maxQuestions.value,
@@ -662,6 +728,10 @@ async function generateCandidates(): Promise<void> {
pageNotice.value = t('brands.questions.errors.emptyInput')
return
}
if (tooShortAiSeedTopics.value.length) {
setAiSeedTopicError(buildAiSeedTopicTooShortMessage(tooShortAiSeedTopics.value))
return
}
beginAiCandidateTask('candidate')
try {
await previewMutations.ai.mutateAsync()
@@ -947,11 +1017,15 @@ function backToBrands(): void {
class="seed-topic-select"
mode="tags"
:value="aiForm.seed_topics"
:status="aiSeedTopicError ? 'error' : undefined"
:placeholder="t('brands.questions.ai.seedTopicPlaceholder')"
:token-separators="aiTopicTokenSeparators"
:options="[]"
@change="(value: string[]) => updateAiSeedTopics(value)"
/>
<p v-if="aiSeedTopicError" class="field-item__error">
{{ aiSeedTopicError }}
</p>
<p class="field-item__hint">
{{ t('brands.questions.ai.seedTopicHint') }}
</p>
@@ -1284,6 +1358,13 @@ function backToBrands(): void {
line-height: 1.6;
}
.field-item__error {
margin: 0;
color: #b42318;
font-size: 13px;
line-height: 1.6;
}
.seed-topic-select {
width: 100%;
}