Files
geo/apps/admin-web/src/views/BrandQuestionCreateView.vue
T
root 41f5623791
Frontend CI / Frontend (push) Successful in 4m25s
Backend CI / Backend (push) Successful in 15m23s
feat: require brand description in create and update operations, add validation messages
2026-06-09 14:09:05 +08:00

1840 lines
50 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import {
DeleteOutlined,
EditOutlined,
LeftOutlined,
RobotOutlined,
SaveOutlined,
ThunderboltOutlined,
} from '@ant-design/icons-vue'
import { ApiClientError } from '@geo/http-client'
import type { BrandLibrarySummary, QuestionCandidate, QuestionSource } from '@geo/shared-types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { message } from 'ant-design-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,
getQuestionMaterializeErrorMessage,
isDuplicateQuestionSkipReason,
} from '@/lib/errors'
type CreateMode = 'combination' | 'ai' | 'batch'
interface CandidateRow extends QuestionCandidate {
id: string
selected: boolean
}
const AI_SEED_TOPIC_MIN_LENGTH = 2
const queryClient = useQueryClient()
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const currentStep = ref(0)
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')
const aiSeedTopicError = ref('')
let aiCandidateProgressTimer: number | null = null
let aiCandidateCloseTimer: number | null = null
const vFocus = {
mounted(el: HTMLElement) {
const input =
el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement
? el
: el.querySelector<HTMLInputElement | HTMLTextAreaElement>('input, textarea')
if (input) {
input.focus()
const len = input.value.length
input.setSelectionRange(len, len)
}
},
}
const expansionForm = reactive({
region: [''],
prefix: [''],
core: [''],
industry: [''],
suffix: [''],
})
const aiForm = reactive({
seed_topics: [] as string[],
})
const aiTopicTokenSeparators = [',', '', ';', '', '\n']
const batchForm = reactive({
text: '',
})
const brandId = computed(() => {
const value = Number(route.params.brandId)
return Number.isNaN(value) || value <= 0 ? 0 : value
})
const brandQuery = useQuery({
queryKey: computed(() => ['brands', 'detail', brandId.value]),
enabled: computed(() => brandId.value > 0),
queryFn: () => brandsApi.detail(brandId.value),
})
const brandLibrarySummaryQuery = useQuery({
queryKey: ['brands', 'library-summary'],
queryFn: () => brandsApi.getLibrarySummary(),
})
const questionsQuery = useQuery({
queryKey: computed(() => ['brands', brandId.value, 'questions', 'all']),
enabled: computed(() => brandId.value > 0),
queryFn: () => brandsApi.listQuestions(brandId.value),
})
const brandLibrarySummary = computed<BrandLibrarySummary | null>(
() => brandLibrarySummaryQuery.data.value ?? null,
)
const currentQuestions = computed(() => questionsQuery.data.value ?? [])
const maxQuestions = computed(
() =>
brandLibrarySummary.value?.max_questions ??
brandLibrarySummary.value?.max_questions_per_brand ??
0,
)
const usedQuestions = computed(
() => brandLibrarySummary.value?.used_questions ?? currentQuestions.value.length,
)
const remainingQuestions = computed(() => {
if (typeof brandLibrarySummary.value?.remaining_questions === 'number') {
return brandLibrarySummary.value.remaining_questions
}
if (!maxQuestions.value) {
return 0
}
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) {
return false
}
return usedQuestions.value >= maxQuestions.value
})
const existingQuestionSet = computed(() => {
const keys = new Set<string>()
for (const question of currentQuestions.value) {
keys.add(normalizeQuestionKey(question.question_text))
}
return keys
})
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
const tooShort = candidateRows.value.filter((item) => item.too_short).length
const duplicate = candidateRows.value.filter((item) => item.duplicate).length
return { total, selected, tooShort, duplicate }
})
const expansionToolTotal = computed(() => {
const core = splitLines(expansionForm.core).length
const industry = splitLines(expansionForm.industry).length
if (!core || !industry) {
return 0
}
return (
combinationPartCount(expansionForm.region) *
combinationPartCount(expansionForm.prefix) *
core *
industry *
combinationPartCount(expansionForm.suffix)
)
})
const expansionToolPreviewCount = computed(() => Math.min(expansionToolTotal.value, 500))
const createModeSource = computed<QuestionSource>(() => {
if (createMode.value === 'ai') {
return 'ai_distill'
}
if (createMode.value === 'batch') {
return 'manual'
}
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 ||
brandLibrarySummaryQuery.isPending.value ||
questionsQuery.isPending.value,
)
const isGenerating = computed(
() =>
previewMutations.combination.isPending.value ||
previewMutations.ai.isPending.value ||
combinationFillMutation.isPending.value,
)
const previewMutations = {
combination: useMutation({
mutationFn: () =>
brandsApi.previewQuestionCombination(brandId.value, {
region: splitLines(expansionForm.region),
prefix: splitLines(expansionForm.prefix),
core: splitLines(expansionForm.core),
industry: splitLines(expansionForm.industry),
suffix: splitLines(expansionForm.suffix),
max_items: 500,
}),
onSuccess: (result) => {
candidateRows.value = toCandidateRows(result.candidates)
pageNotice.value = result.truncated ? t('brands.questions.messages.truncated') : ''
currentStep.value = 1
},
onError: (error) => {
pageNotice.value = formatError(error)
},
}),
ai: useMutation({
mutationFn: async () => {
const topics = normalizedAiSeedTopics.value
const results = await Promise.all(
topics.map((topic) =>
brandsApi.distillQuestions(brandId.value, {
seed_topic: topic,
}),
),
)
return mergeQuestionCandidateResults(results)
},
onSuccess: (result) => {
candidateRows.value = toCandidateRows(result.candidates)
if (result.cache_hit) {
pageNotice.value = t('brands.questions.messages.cacheHit')
} else if (result.ai_points_charged) {
pageNotice.value = t('brands.questions.messages.aiCharged', {
count: result.ai_points_charged,
})
} else {
pageNotice.value = ''
}
currentStep.value = 1
},
onError: (error) => {
handleAiPreviewError(error)
},
}),
}
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, {
source: createModeSource.value,
questions: selectedCandidates.value.map((item) => ({ text: item.text.trim() })),
}),
onSuccess: async (result) => {
if (result.created_questions > 0) {
message.success(
t('brands.questions.messages.partialSaved', {
created: result.created_questions,
skipped: result.skipped_questions.length,
}),
)
await queryClient.invalidateQueries({ queryKey: ['brands'] })
await router.push({
name: 'brands',
query: { brand_id: String(brandId.value) },
})
return
}
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) => {
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)
},
})
function splitLines(value: string | string[]): string[] {
if (Array.isArray(value)) {
return value.map(normalizeSearchTermText).filter(Boolean)
}
return value
.split(/\r?\n|,/)
.map(normalizeSearchTermText)
.filter(Boolean)
}
function normalizedTopicParts(value: string): string[] {
return value
.split(/\r?\n|,||;|/)
.map((item) => item.trim())
.filter(Boolean)
}
const normalizedAiSeedTopics = computed(() => {
const seen = new Set<string>()
const topics: string[] = []
for (const rawTopic of aiForm.seed_topics) {
for (const topic of normalizedTopicParts(rawTopic)) {
const key = topic.toLowerCase()
if (seen.has(key)) {
continue
}
seen.add(key)
topics.push(topic)
}
}
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))
}
function dedupeTopicParts(values: string[]): string[] {
const seen = new Set<string>()
const topics: string[] = []
for (const value of values) {
for (const topic of normalizedTopicParts(value)) {
const key = topic.toLowerCase()
if (seen.has(key)) {
continue
}
seen.add(key)
topics.push(topic)
}
}
return topics
}
function mergeQuestionCandidateResults(
results: Awaited<ReturnType<typeof brandsApi.distillQuestions>>[],
) {
const seen = new Set<string>()
const candidates: QuestionCandidate[] = []
let aiPointsCharged = 0
let cacheHit = false
let truncated = false
for (const result of results) {
aiPointsCharged += result.ai_points_charged ?? 0
cacheHit = cacheHit || Boolean(result.cache_hit)
truncated = truncated || Boolean(result.truncated)
for (const candidate of result.candidates) {
const key = normalizeQuestionKey(candidate.text)
if (!key || seen.has(key)) {
continue
}
seen.add(key)
candidates.push(candidate)
}
}
return {
candidates,
ai_points_charged: aiPointsCharged || undefined,
cache_hit: cacheHit,
truncated,
}
}
type ColumnKey = 'region' | 'prefix' | 'core' | 'industry' | 'suffix'
function handleInput(column: ColumnKey, index: number) {
const arr = expansionForm[column]
if (index === arr.length - 1 && arr[index].trim() !== '') {
arr.push('')
}
while (arr.length > 1 && arr[arr.length - 1].trim() === '' && arr[arr.length - 2].trim() === '') {
arr.pop()
}
}
function handleEnter(column: ColumnKey, index: number) {
const arr = expansionForm[column]
if (arr[index].trim() === '') {
return
}
if (index === arr.length - 1) {
arr.push('')
} else {
arr.splice(index + 1, 0, '')
}
setTimeout(() => {
const inputs = document.querySelectorAll(
`.word-column[data-col="${column}"] .word-column__input`,
) as NodeListOf<HTMLInputElement>
if (inputs[index + 1]) {
inputs[index + 1].focus()
}
}, 0)
}
function handleBackspace(column: ColumnKey, index: number, event: KeyboardEvent) {
const arr = expansionForm[column]
if (arr[index] === '' && arr.length > 1) {
event.preventDefault()
arr.splice(index, 1)
setTimeout(() => {
const inputs = document.querySelectorAll(
`.word-column[data-col="${column}"] .word-column__input`,
) as NodeListOf<HTMLInputElement>
const target = inputs[index - 1 >= 0 ? index - 1 : 0]
if (target) {
target.focus()
}
}, 0)
}
}
function handleBlur(column: ColumnKey, index: number) {
setTimeout(() => {
const arr = expansionForm[column]
if (arr[index] !== undefined && arr[index].trim() === '' && index !== arr.length - 1) {
arr.splice(index, 1)
}
}, 150)
}
function removeItem(column: ColumnKey, index: number) {
const arr = expansionForm[column]
if (arr.length > 1) {
arr.splice(index, 1)
} else {
arr[0] = ''
}
}
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
}
function normalizeQuestionKey(value: string): string {
return normalizeSearchTermText(value).toLowerCase()
}
function normalizeSearchTermText(value: string): string {
return value
.trim()
.replace(/[\p{P}]/gu, '')
.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) {
return false
}
return /[\p{L}\p{N}]/u.test(text)
}
function toCandidateRows(candidates: QuestionCandidate[]): CandidateRow[] {
return candidates
.map((candidate, index) => {
const text = normalizeSearchTermText(candidate.text)
const tooShort = text.length < 4
const invalid = !searchTermLooksValid(text)
return {
...candidate,
text,
too_short: candidate.too_short || tooShort,
suggest_skip: candidate.suggest_skip || tooShort || invalid,
id: `${Date.now()}-${index}-${text}`,
selected: false,
}
})
.filter((candidate) => candidate.text)
}
function buildLocalCandidates(texts: string[], source: QuestionSource): CandidateRow[] {
const seen = new Set<string>()
const rows: CandidateRow[] = []
for (const raw of texts) {
const text = normalizeSearchTermText(raw)
if (!text) {
continue
}
const key = normalizeQuestionKey(text)
const duplicateInBatch = seen.has(key)
seen.add(key)
const tooShort = text.length < 4
const duplicate = existingQuestionSet.value.has(key) || duplicateInBatch
const invalid = !searchTermLooksValid(text)
rows.push({
id: `${Date.now()}-${rows.length}-${text}`,
text,
layer: 'L2',
intent: 'informational',
source,
too_short: tooShort,
duplicate,
suggest_skip: tooShort || duplicate || invalid,
selected: false,
})
}
return rows
}
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
}
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 = ''
clearAiSeedTopicError()
if (questionLimitReached.value) {
pageNotice.value = t('brands.questions.messages.limitReached', {
limit: maxQuestions.value,
})
return
}
if (createMode.value === 'batch') {
candidateRows.value = buildLocalCandidates(splitLines(batchForm.text), 'manual')
pageNotice.value = candidateRows.value.length ? '' : t('brands.questions.errors.emptyInput')
if (candidateRows.value.length) {
currentStep.value = 1
}
return
}
if (createMode.value === 'combination') {
if (!splitLines(expansionForm.core).length) {
pageNotice.value = t('brands.questions.combination.coreRequired')
return
}
if (!splitLines(expansionForm.industry).length) {
pageNotice.value = t('brands.questions.combination.industryRequired')
return
}
await previewMutations.combination.mutateAsync()
return
}
if (!normalizedAiSeedTopics.value.length) {
pageNotice.value = t('brands.questions.errors.emptyInput')
return
}
if (tooShortAiSeedTopics.value.length) {
setAiSeedTopicError(buildAiSeedTopicTooShortMessage(tooShortAiSeedTopics.value))
return
}
beginAiCandidateTask('candidate')
try {
await previewMutations.ai.mutateAsync()
} finally {
finishAiCandidateTask()
}
}
function removeCandidate(id: string): void {
candidateRows.value = candidateRows.value.filter((item) => item.id !== id)
}
function refreshCandidate(row: CandidateRow): void {
row.text = normalizeSearchTermText(row.text)
const key = normalizeQuestionKey(row.text)
const duplicateInBatch = candidateRows.value.some(
(item) => item.id !== row.id && normalizeQuestionKey(item.text) === key,
)
row.too_short = row.text.length < 4
row.duplicate = Boolean(key) && (existingQuestionSet.value.has(key) || duplicateInBatch)
row.suggest_skip = row.too_short || row.duplicate || !searchTermLooksValid(row.text)
}
function refreshAllCandidates(): void {
candidateRows.value.forEach(refreshCandidate)
}
function startEditingCandidate(id: string): void {
editingCandidateId.value = id
}
function stopEditingCandidate(row: CandidateRow): void {
editingCandidateId.value = null
refreshCandidate(row)
}
function updateCandidateSelection(row: CandidateRow, checked: boolean): void {
refreshCandidate(row)
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) {
pageNotice.value = t('brands.questions.errors.noSelection')
return
}
if (selectedCandidatesAllExistInLibrary.value) {
pageNotice.value = t('brands.questions.errors.duplicateExisting')
return
}
await materializeMutation.mutateAsync()
}
function backToBrands(): void {
void router.push({
name: 'brands',
query: brandId.value ? { brand_id: String(brandId.value) } : undefined,
})
}
</script>
<template>
<div class="wizard-page">
<div v-if="isPageLoading" class="wizard-page__loading">
<a-skeleton active :paragraph="{ rows: 12 }" />
</div>
<template v-else>
<header class="wizard-page__header">
<div class="header-left">
<a-button type="text" shape="circle" @click="backToBrands">
<template #icon><LeftOutlined /></template>
</a-button>
<div class="header-title-box">
<h2 class="header-title">
{{ t('brands.questions.page.title') }}
<span class="header-accent">{{ brandQuery.data.value?.name }}</span>
</h2>
<p class="header-eyebrow">{{ t('brands.questions.page.subtitle') }}</p>
</div>
</div>
</header>
<main class="wizard-page__main">
<div class="wizard-steps-container">
<a-steps :current="currentStep" class="wizard-steps" size="small">
<a-step :title="t('brands.questions.page.steps.configure')" />
<a-step :title="t('brands.questions.page.steps.preview')" />
</a-steps>
</div>
<a-alert
v-if="pageNotice"
class="page-alert"
type="warning"
show-icon
:message="pageNotice"
/>
<section v-if="currentStep === 0" class="wizard-section">
<div class="wizard-card">
<div class="wizard-card__header">
<div>
<h3>{{ t('brands.questions.page.methodTitle') }}</h3>
<p>{{ t('brands.questions.page.methodHint') }}</p>
</div>
</div>
<div class="method-grid">
<button
type="button"
class="method-card"
:class="{ 'method-card--active': createMode === 'combination' }"
@click="changeCreateMode('combination')"
>
<strong>{{ t('brands.questions.modal.methods.combination') }}</strong>
<span>{{ t('brands.questions.page.methods.combination') }}</span>
</button>
<button
type="button"
class="method-card"
:class="{ 'method-card--active': createMode === 'ai' }"
@click="changeCreateMode('ai')"
>
<strong>{{ t('brands.questions.modal.methods.aiDistill') }}</strong>
<span>{{ t('brands.questions.page.methods.ai') }}</span>
</button>
<button
type="button"
class="method-card"
:class="{ 'method-card--active': createMode === 'batch' }"
@click="changeCreateMode('batch')"
>
<strong>{{ t('brands.questions.modal.methods.manualBatch') }}</strong>
<span>{{ t('brands.questions.page.methods.batch') }}</span>
</button>
</div>
</div>
<div v-if="createMode === 'combination'" class="wizard-card">
<div class="wizard-card__header">
<div>
<h3>{{ t('brands.questions.modal.methods.combination') }}</h3>
<p>{{ t('brands.questions.page.expansionHint') }}</p>
</div>
<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>
<div class="expansion-tool">
<div class="word-column" data-col="region">
<div class="word-column__head">
<strong>{{ t('brands.questions.combination.region') }}</strong>
<span>
{{
splitLines(expansionForm.region).length ||
t('brands.questions.combination.defaultWord')
}}
</span>
</div>
<div class="word-column__list">
<div
v-for="(item, index) in expansionForm.region"
:key="index"
class="word-column__item"
>
<span class="word-column__dot"></span>
<input
v-model="expansionForm.region[index]"
class="word-column__input"
placeholder="输入词语"
@input="handleInput('region', index)"
@keydown.enter.prevent="handleEnter('region', index)"
@keydown.backspace="handleBackspace('region', index, $event)"
@blur="handleBlur('region', index)"
/>
<button
v-if="index < expansionForm.region.length - 1"
class="word-column__remove"
tabindex="-1"
@click="removeItem('region', index)"
>
×
</button>
</div>
</div>
</div>
<div class="word-column" data-col="prefix">
<div class="word-column__head">
<strong>{{ t('brands.questions.combination.prefix') }}</strong>
<span>
{{
splitLines(expansionForm.prefix).length ||
t('brands.questions.combination.defaultWord')
}}
</span>
</div>
<div class="word-column__list">
<div
v-for="(item, index) in expansionForm.prefix"
:key="index"
class="word-column__item"
>
<span class="word-column__dot"></span>
<input
v-model="expansionForm.prefix[index]"
class="word-column__input"
placeholder="输入词语"
@input="handleInput('prefix', index)"
@keydown.enter.prevent="handleEnter('prefix', index)"
@keydown.backspace="handleBackspace('prefix', index, $event)"
@blur="handleBlur('prefix', index)"
/>
<button
v-if="index < expansionForm.prefix.length - 1"
class="word-column__remove"
tabindex="-1"
@click="removeItem('prefix', index)"
>
×
</button>
</div>
</div>
</div>
<div class="word-column word-column--required" data-col="core">
<div class="word-column__head">
<strong>{{ t('brands.questions.combination.core') }}</strong>
<span>{{ splitLines(expansionForm.core).length }}</span>
</div>
<div class="word-column__list">
<div
v-for="(item, index) in expansionForm.core"
:key="index"
class="word-column__item"
>
<span class="word-column__dot"></span>
<input
v-model="expansionForm.core[index]"
class="word-column__input"
placeholder="输入词语"
@input="handleInput('core', index)"
@keydown.enter.prevent="handleEnter('core', index)"
@keydown.backspace="handleBackspace('core', index, $event)"
@blur="handleBlur('core', index)"
/>
<button
v-if="index < expansionForm.core.length - 1"
class="word-column__remove"
tabindex="-1"
@click="removeItem('core', index)"
>
×
</button>
</div>
</div>
</div>
<div class="word-column word-column--required" data-col="industry">
<div class="word-column__head">
<strong>{{ t('brands.questions.combination.industry') }}</strong>
<span>{{ splitLines(expansionForm.industry).length }}</span>
</div>
<div class="word-column__list">
<div
v-for="(item, index) in expansionForm.industry"
:key="index"
class="word-column__item"
>
<span class="word-column__dot"></span>
<input
v-model="expansionForm.industry[index]"
class="word-column__input"
placeholder="输入词语"
@input="handleInput('industry', index)"
@keydown.enter.prevent="handleEnter('industry', index)"
@keydown.backspace="handleBackspace('industry', index, $event)"
@blur="handleBlur('industry', index)"
/>
<button
v-if="index < expansionForm.industry.length - 1"
class="word-column__remove"
tabindex="-1"
@click="removeItem('industry', index)"
>
×
</button>
</div>
</div>
</div>
<div class="word-column" data-col="suffix">
<div class="word-column__head">
<strong>{{ t('brands.questions.combination.suffix') }}</strong>
<span>
{{
splitLines(expansionForm.suffix).length ||
t('brands.questions.combination.defaultWord')
}}
</span>
</div>
<div class="word-column__list">
<div
v-for="(item, index) in expansionForm.suffix"
:key="index"
class="word-column__item"
>
<span class="word-column__dot"></span>
<input
v-model="expansionForm.suffix[index]"
class="word-column__input"
placeholder="输入词语"
@input="handleInput('suffix', index)"
@keydown.enter.prevent="handleEnter('suffix', index)"
@keydown.backspace="handleBackspace('suffix', index, $event)"
@blur="handleBlur('suffix', index)"
/>
<button
v-if="index < expansionForm.suffix.length - 1"
class="word-column__remove"
tabindex="-1"
@click="removeItem('suffix', index)"
>
×
</button>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="createMode === 'ai'" class="wizard-card">
<div class="field-item field-item--compact">
<label>{{ t('brands.questions.ai.seedTopic') }}</label>
<a-select
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>
</div>
</div>
<div v-else class="wizard-card">
<div class="wizard-card__header">
<div>
<h3>{{ t('brands.questions.modal.methods.manualBatch') }}</h3>
<p>{{ t('brands.questions.page.batchHint') }}</p>
</div>
</div>
<div class="field-item">
<label>{{ t('brands.questions.batch.label') }}</label>
<a-textarea v-model:value="batchForm.text" :rows="12" />
</div>
</div>
</section>
<section v-else class="wizard-section">
<div class="wizard-card">
<div class="wizard-card__header">
<div>
<h3>{{ t('brands.questions.modal.preview') }}</h3>
<p>
{{
t('brands.questions.modal.summary', {
total: candidateStats.total,
selected: candidateStats.selected,
tooShort: candidateStats.tooShort,
duplicate: candidateStats.duplicate,
})
}}
</p>
</div>
</div>
<div class="candidate-list">
<div v-if="!candidateRows.length" class="candidate-empty">
{{ t('brands.questions.emptyCandidates') }}
</div>
<div v-for="row in candidateRows" :key="row.id" class="candidate-row">
<a-checkbox
:checked="row.selected"
:disabled="isCandidateSelectionDisabled(row)"
@change="
(event: Event) =>
updateCandidateSelection(row, (event.target as HTMLInputElement).checked)
"
/>
<a-textarea
v-if="editingCandidateId === row.id"
v-focus
v-model:value="row.text"
class="candidate-row__editor"
auto-size
@blur="stopEditingCandidate(row)"
@keydown.enter="stopEditingCandidate(row)"
/>
<span v-else class="candidate-row__text" @dblclick="startEditingCandidate(row.id)">
{{ row.text }}
</span>
<a-tag v-if="editingCandidateId !== row.id && row.duplicate" color="orange">
{{ t('brands.questions.badges.duplicate') }}
</a-tag>
<a-tag v-else-if="editingCandidateId !== row.id && row.too_short" color="red">
{{ t('brands.questions.badges.tooShort') }}
</a-tag>
<div v-if="editingCandidateId !== row.id" class="candidate-row__actions">
<a-button
type="text"
shape="circle"
class="action-btn-edit"
@click="startEditingCandidate(row.id)"
>
<EditOutlined />
</a-button>
<a-button type="text" shape="circle" danger @click="removeCandidate(row.id)">
<DeleteOutlined />
</a-button>
</div>
</div>
</div>
</div>
</section>
</main>
<footer class="wizard-page__footer">
<div class="footer-meta">
{{
t('brands.questions.usage', {
used: displayUsedQuestions,
total: maxQuestions || '--',
})
}}
<span>{{ t('brands.questions.remaining', { count: displayRemainingQuestions }) }}</span>
</div>
<div class="footer-actions">
<a-button @click="backToBrands">{{ t('common.cancel') }}</a-button>
<a-button v-if="currentStep > 0" @click="currentStep = 0">
{{ t('common.previous') }}
</a-button>
<a-button
v-if="currentStep === 0"
type="primary"
:loading="isGenerating"
@click="generateCandidates"
>
<template #icon><ThunderboltOutlined /></template>
{{ t('brands.questions.generate') }}
</a-button>
<a-button
v-else
type="primary"
:disabled="!selectedCandidates.length"
:loading="materializeMutation.isPending.value"
@click="saveCandidates"
>
<template #icon><SaveOutlined /></template>
{{ t('brands.questions.modal.save', { count: selectedCandidates.length }) }}
</a-button>
</div>
</footer>
</template>
<AiWaitingModal
:open="aiCandidateModalOpen"
:title="aiWaitingTitle"
:progress="aiCandidateProgress"
:stage="aiCandidateStages[aiCandidateStageIndex]"
/>
</div>
</template>
<style scoped>
.wizard-page {
display: flex;
flex-direction: column;
min-height: calc(100vh - 120px);
background: #ffffff;
}
.wizard-page__loading {
padding: 40px;
}
.wizard-page__header {
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 32px;
background: #ffffff;
border-bottom: 1px solid #edf2f7;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-title-box {
display: flex;
flex-direction: column;
gap: 4px;
}
.header-title {
margin: 0;
color: #111827;
font-size: 20px;
font-weight: 800;
}
.header-accent {
margin-left: 8px;
color: #1677ff;
font-size: 14px;
font-weight: 600;
}
.header-eyebrow {
margin: 0;
color: #6d7c94;
font-size: 13px;
}
.wizard-page__main {
display: flex;
flex-direction: column;
width: 100%;
max-width: 1160px;
margin: 0 auto;
padding: 24px 32px 64px;
}
.wizard-steps-container {
padding: 0 0 24px;
border-bottom: 1px solid #edf2f7;
}
.page-alert {
margin-top: 20px;
}
.wizard-section {
display: flex;
flex-direction: column;
}
.wizard-card {
padding: 32px 0;
border-bottom: 1px solid #edf2f7;
}
.wizard-section > .wizard-card:last-child {
border-bottom: none;
}
.wizard-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.wizard-card__header h3 {
display: flex;
align-items: center;
margin: 0;
color: #111827;
font-size: 16px;
font-weight: 800;
}
.wizard-card__header h3::before {
display: inline-block;
width: 4px;
height: 16px;
margin-right: 12px;
background: #111827;
border-radius: 2px;
content: '';
}
.wizard-card__header p {
margin: 6px 0 0 16px;
color: #667085;
font-size: 13px;
line-height: 1.7;
}
.method-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.method-card {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 112px;
padding: 18px;
text-align: left;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition:
border-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
}
.method-card:hover {
border-color: #94a3b8;
transform: translateY(-1px);
box-shadow: 0 16px 30px rgba(15, 23, 42, 0.06);
}
.method-card--active {
border-color: #2563eb;
background: #f8fbff;
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.12);
}
.method-card strong {
color: #111827;
font-size: 15px;
font-weight: 800;
}
.method-card span {
color: #64748b;
font-size: 13px;
line-height: 1.6;
}
.field-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.field-item--compact {
margin-top: 20px;
}
.field-item > label {
display: inline-flex;
align-items: center;
margin-bottom: 12px;
color: #1f2937;
font-size: 15px;
font-weight: 700;
}
.field-item > label::after {
content: '';
}
.field-item__hint {
margin: 0;
color: #64748b;
font-size: 13px;
line-height: 1.6;
}
.field-item__error {
margin: 0;
color: #b42318;
font-size: 13px;
line-height: 1.6;
}
.seed-topic-select {
width: 100%;
}
.seed-topic-select :deep(.ant-select-selector) {
min-height: 52px;
padding: 8px 12px;
border-color: #d9d9d9 !important;
border-radius: 8px;
}
.seed-topic-select :deep(.ant-select-selection-overflow) {
gap: 6px;
}
.seed-topic-select :deep(.ant-select-selection-item) {
display: inline-flex;
align-items: center;
max-width: 100%;
min-height: 28px;
color: #1e293b;
background: #f1f5f9;
border-color: #dbe3ee;
border-radius: 6px;
}
.tool-header-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.tool-ai-fill {
background: #1677ff !important;
border: none !important;
color: #ffffff !important;
font-weight: 600;
border-radius: 8px;
height: 40px;
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.15);
transition: all 0.2s ease;
padding: 0 16px;
}
.tool-ai-fill:hover:not([disabled]) {
background: #0958d9 !important;
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.2);
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 {
padding: 8px 12px;
color: #475569;
font-size: 13px;
text-align: right;
background: #f8fafc;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.expansion-tool {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 16px;
}
.word-column {
display: flex;
flex-direction: column;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
transition: all 0.2s ease;
}
.word-column:focus-within {
background: #ffffff;
border-color: #1677ff;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.05),
0 0 0 2px rgba(22, 119, 255, 0.1);
}
.word-column__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 14px 16px;
background: #f1f5f9;
border-bottom: 1px solid #e2e8f0;
transition: background 0.2s ease;
}
.word-column:focus-within .word-column__head {
background: #ffffff;
}
.word-column__head strong {
color: #334155;
font-size: 14px;
font-weight: 700;
}
.word-column__head span {
color: #64748b;
font-size: 12px;
}
.word-column--required .word-column__head {
background: #f0f7ff;
border-bottom-color: #dbeafe;
}
.word-column--required:focus-within .word-column__head {
background: #ffffff;
border-bottom-color: #e2e8f0;
}
.word-column--required .word-column__head strong {
color: #1d4ed8;
}
.word-column--required .word-column__head span {
color: #3b82f6;
}
.word-column__list {
display: flex;
flex-direction: column;
padding: 8px 0;
overflow-y: auto;
min-height: 280px;
max-height: 400px;
}
.word-column__list::-webkit-scrollbar {
width: 4px;
}
.word-column__list::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.word-column__item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 16px;
position: relative;
}
.word-column__item:hover .word-column__remove {
opacity: 1;
}
.word-column__dot {
width: 4px;
height: 4px;
border-radius: 50%;
background-color: #cbd5e1;
flex-shrink: 0;
transition: background-color 0.2s;
}
.word-column__item:focus-within .word-column__dot {
background-color: #1677ff;
}
.word-column__input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
color: #1e293b;
font-size: 14px;
padding: 4px 0;
outline: none;
}
.word-column__input::placeholder {
color: #94a3b8;
}
.word-column__remove {
opacity: 0;
background: transparent;
border: none;
color: #94a3b8;
font-size: 16px;
cursor: pointer;
padding: 0 4px;
transition:
opacity 0.2s,
color 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.word-column__remove:hover {
color: #ef4444;
}
.candidate-list {
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;
}
.candidate-empty {
grid-column: 1 / -1;
padding: 32px;
color: #94a3b8;
text-align: center;
background: #ffffff;
border: 1px dashed #d8e0ec;
border-radius: 8px;
}
.candidate-row {
display: grid;
grid-template-columns: 24px minmax(0, 1fr) auto auto;
gap: 12px;
align-items: center;
min-height: 58px;
padding: 12px 16px;
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: #fbfdff;
border-color: #b7d6ff;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.04);
}
.candidate-row__text {
padding: 4px 11px;
font-size: 14px;
color: #1e293b;
cursor: pointer;
white-space: pre-wrap;
word-break: break-all;
}
.candidate-row__editor {
width: 100%;
min-width: 0;
min-height: 44px;
line-height: 1.6;
white-space: pre-wrap;
resize: none;
}
.candidate-row__editor :deep(.ant-input) {
width: 100%;
min-height: 44px;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
resize: none;
}
.candidate-row__actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s;
}
.candidate-row:hover .candidate-row__actions,
.candidate-row:focus-within .candidate-row__actions {
opacity: 1;
}
.action-btn-edit:hover {
color: #52c41a !important;
background: #f6ffed !important;
}
.wizard-page__footer {
position: sticky;
bottom: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
margin-top: auto;
background: rgba(255, 255, 255, 0.9);
border-top: 1px solid rgba(20, 44, 88, 0.08);
backdrop-filter: blur(14px);
}
.footer-meta {
color: #475569;
font-size: 13px;
}
.footer-meta span {
margin-left: 12px;
color: #64748b;
}
.footer-actions {
display: flex;
gap: 12px;
}
@media (max-width: 1024px) {
.method-grid,
.expansion-tool {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.word-column {
border-bottom: 1px solid #ece7f7;
}
}
@media (max-width: 768px) {
.wizard-page__header {
padding: 14px 16px;
}
.wizard-page__main {
padding: 18px 16px 48px;
}
.method-grid,
.expansion-tool {
grid-template-columns: 1fr;
}
.wizard-card__header {
flex-direction: column;
}
.candidate-row {
grid-template-columns: 24px minmax(0, 1fr) auto auto;
}
.wizard-page__footer {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.footer-actions {
justify-content: flex-end;
}
}
</style>