Files
geo/apps/admin-web/src/views/BrandQuestionCreateView.vue
T

1816 lines
49 KiB
Vue
Raw Normal View History

<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 } 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 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 = t('brands.questions.errors.noValidQuestions')
},
onError: (error) => {
if (error instanceof ApiClientError && error.message === 'no_valid_questions') {
pageNotice.value = t('brands.questions.errors.noValidQuestions')
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
}
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>