2026-05-12 21:53:36 +08:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import {
|
|
|
|
|
|
DeleteOutlined,
|
|
|
|
|
|
EditOutlined,
|
2026-05-13 15:59:39 +08:00
|
|
|
|
BulbOutlined,
|
2026-05-12 21:53:36 +08:00
|
|
|
|
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'
|
2026-05-13 15:59:39 +08:00
|
|
|
|
import { computed, onBeforeUnmount, reactive, ref } from 'vue'
|
2026-05-12 21:53:36 +08:00
|
|
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
|
|
|
2026-05-13 15:59:39 +08:00
|
|
|
|
import AiWaitingModal from '@/components/AiWaitingModal.vue'
|
2026-05-12 21:53:36 +08:00
|
|
|
|
import { brandsApi } from '@/lib/api'
|
|
|
|
|
|
import { formatError } from '@/lib/errors'
|
|
|
|
|
|
|
|
|
|
|
|
type CreateMode = 'combination' | 'ai' | 'batch'
|
|
|
|
|
|
|
|
|
|
|
|
interface CandidateRow extends QuestionCandidate {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
selected: boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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('')
|
2026-05-13 15:59:39 +08:00
|
|
|
|
const aiCandidateModalOpen = ref(false)
|
|
|
|
|
|
const aiCandidateProgress = ref(12)
|
|
|
|
|
|
const aiCandidateStageIndex = ref(0)
|
|
|
|
|
|
const aiWaitingKind = ref<'candidate' | 'fill'>('candidate')
|
|
|
|
|
|
|
|
|
|
|
|
let aiCandidateProgressTimer: number | null = null
|
|
|
|
|
|
let aiCandidateCloseTimer: number | null = null
|
2026-05-12 21:53:36 +08:00
|
|
|
|
|
|
|
|
|
|
const vFocus = {
|
|
|
|
|
|
mounted(el: HTMLElement) {
|
2026-05-13 15:59:39 +08:00
|
|
|
|
const input =
|
|
|
|
|
|
el instanceof HTMLInputElement ? el : el.querySelector<HTMLInputElement>('input')
|
2026-05-12 21:53:36 +08:00
|
|
|
|
if (input) {
|
|
|
|
|
|
input.focus()
|
|
|
|
|
|
const len = input.value.length
|
|
|
|
|
|
input.setSelectionRange(len, len)
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const expansionForm = reactive({
|
2026-05-13 15:59:39 +08:00
|
|
|
|
region: [''],
|
|
|
|
|
|
prefix: [''],
|
|
|
|
|
|
core: [''],
|
|
|
|
|
|
industry: [''],
|
|
|
|
|
|
suffix: [''],
|
2026-05-12 21:53:36 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const aiForm = reactive({
|
|
|
|
|
|
seed_topic: '',
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
})
|
2026-05-13 15:59:39 +08:00
|
|
|
|
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
|
|
|
|
|
|
})
|
2026-05-12 21:53:36 +08:00
|
|
|
|
|
|
|
|
|
|
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'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-13 15:59:39 +08:00
|
|
|
|
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'),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-12 21:53:36 +08:00
|
|
|
|
const isPageLoading = computed(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
brandQuery.isPending.value ||
|
|
|
|
|
|
brandLibrarySummaryQuery.isPending.value ||
|
|
|
|
|
|
questionsQuery.isPending.value,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const isGenerating = computed(
|
2026-05-13 15:59:39 +08:00
|
|
|
|
() =>
|
|
|
|
|
|
previewMutations.combination.isPending.value ||
|
|
|
|
|
|
previewMutations.ai.isPending.value ||
|
|
|
|
|
|
combinationFillMutation.isPending.value,
|
2026-05-12 21:53:36 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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: () =>
|
|
|
|
|
|
brandsApi.distillQuestions(brandId.value, {
|
|
|
|
|
|
seed_topic: aiForm.seed_topic.trim(),
|
|
|
|
|
|
}),
|
|
|
|
|
|
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) => {
|
|
|
|
|
|
pageNotice.value = formatError(error)
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 15:59:39 +08:00
|
|
|
|
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)
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-12 21:53:36 +08:00
|
|
|
|
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((item) => item.trim()).filter(Boolean)
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
|
|
|
|
|
.split(/\r?\n|,/)
|
|
|
|
|
|
.map((item) => item.trim())
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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] = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 15:59:39 +08:00
|
|
|
|
function fillExpansionColumn(column: ColumnKey, values: string[]): void {
|
|
|
|
|
|
const cleaned = splitLines(values).slice(0, 4)
|
|
|
|
|
|
expansionForm[column].splice(0, expansionForm[column].length, ...cleaned, '')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 21:53:36 +08:00
|
|
|
|
function combinationPartCount(value: string | string[]): number {
|
|
|
|
|
|
const count = splitLines(value).length
|
|
|
|
|
|
return count > 0 ? count : 1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeQuestionKey(value: string): string {
|
|
|
|
|
|
return value.trim().toLowerCase()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function questionLooksLikeQuestion(value: string): boolean {
|
|
|
|
|
|
const text = value.trim()
|
|
|
|
|
|
if (text.length < 4) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return /[??]|什么|如何|怎么|哪|为什么|是否|能不能|适合|区别|对比|推荐/.test(text)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toCandidateRows(candidates: QuestionCandidate[]): CandidateRow[] {
|
|
|
|
|
|
return candidates.map((candidate, index) => ({
|
|
|
|
|
|
...candidate,
|
|
|
|
|
|
id: `${Date.now()}-${index}-${candidate.text}`,
|
2026-05-13 15:59:39 +08:00
|
|
|
|
selected: false,
|
2026-05-12 21:53:36 +08:00
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildLocalCandidates(texts: string[], source: QuestionSource): CandidateRow[] {
|
|
|
|
|
|
const seen = new Set<string>()
|
|
|
|
|
|
const rows: CandidateRow[] = []
|
|
|
|
|
|
for (const raw of texts) {
|
|
|
|
|
|
const text = raw.trim()
|
|
|
|
|
|
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 = !questionLooksLikeQuestion(text)
|
|
|
|
|
|
rows.push({
|
|
|
|
|
|
id: `${Date.now()}-${rows.length}-${text}`,
|
|
|
|
|
|
text,
|
|
|
|
|
|
layer: 'L2',
|
|
|
|
|
|
intent: 'informational',
|
|
|
|
|
|
source,
|
|
|
|
|
|
too_short: tooShort,
|
|
|
|
|
|
duplicate,
|
|
|
|
|
|
suggest_skip: tooShort || duplicate || invalid,
|
2026-05-13 15:59:39 +08:00
|
|
|
|
selected: false,
|
2026-05-12 21:53:36 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return rows
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function changeCreateMode(mode: CreateMode): void {
|
|
|
|
|
|
if (createMode.value === mode) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
createMode.value = mode
|
|
|
|
|
|
currentStep.value = 0
|
|
|
|
|
|
candidateRows.value = []
|
|
|
|
|
|
pageNotice.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 15:59:39 +08:00
|
|
|
|
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()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-12 21:53:36 +08:00
|
|
|
|
async function generateCandidates(): Promise<void> {
|
|
|
|
|
|
pageNotice.value = ''
|
|
|
|
|
|
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 (!aiForm.seed_topic.trim()) {
|
|
|
|
|
|
pageNotice.value = t('brands.questions.errors.emptyInput')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-13 15:59:39 +08:00
|
|
|
|
beginAiCandidateTask('candidate')
|
|
|
|
|
|
try {
|
|
|
|
|
|
await previewMutations.ai.mutateAsync()
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
finishAiCandidateTask()
|
|
|
|
|
|
}
|
2026-05-12 21:53:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function removeCandidate(id: string): void {
|
|
|
|
|
|
candidateRows.value = candidateRows.value.filter((item) => item.id !== id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function refreshCandidate(row: CandidateRow): void {
|
|
|
|
|
|
row.text = row.text.trim()
|
|
|
|
|
|
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 || !questionLooksLikeQuestion(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)
|
2026-05-13 15:59:39 +08:00
|
|
|
|
if (checked && !row.selected && !canSelectMoreCandidates.value) {
|
|
|
|
|
|
pageNotice.value = t('brands.questions.messages.selectionLimitReached', {
|
|
|
|
|
|
limit: maxQuestions.value,
|
|
|
|
|
|
})
|
2026-05-12 21:53:36 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-13 15:59:39 +08:00
|
|
|
|
pageNotice.value = ''
|
2026-05-12 21:53:36 +08:00
|
|
|
|
row.selected = checked
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 15:59:39 +08:00
|
|
|
|
function isCandidateSelectionDisabled(row: CandidateRow): boolean {
|
|
|
|
|
|
return !row.selected && !canSelectMoreCandidates.value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 21:53:36 +08:00
|
|
|
|
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>
|
2026-05-13 15:59:39 +08:00
|
|
|
|
<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>
|
2026-05-12 21:53:36 +08:00
|
|
|
|
</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="wizard-ai-banner">
|
|
|
|
|
|
<div class="wizard-ai-banner__info">
|
|
|
|
|
|
<RobotOutlined class="wizard-ai-banner__icon" />
|
|
|
|
|
|
<div class="wizard-ai-banner__text">
|
|
|
|
|
|
<h4>{{ t('brands.questions.ai.generate') }}</h4>
|
|
|
|
|
|
<p>{{ t('brands.questions.ai.note') }}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="field-item field-item--compact">
|
|
|
|
|
|
<label>{{ t('brands.questions.ai.seedTopic') }}</label>
|
|
|
|
|
|
<a-input v-model:value="aiForm.seed_topic" />
|
|
|
|
|
|
</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"
|
2026-05-13 15:59:39 +08:00
|
|
|
|
:disabled="isCandidateSelectionDisabled(row)"
|
2026-05-12 21:53:36 +08:00
|
|
|
|
@change="(event: Event) => updateCandidateSelection(row, (event.target as HTMLInputElement).checked)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<a-input
|
|
|
|
|
|
v-if="editingCandidateId === row.id"
|
|
|
|
|
|
v-focus
|
|
|
|
|
|
v-model:value="row.text"
|
|
|
|
|
|
@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="row.duplicate" color="orange">
|
|
|
|
|
|
{{ t('brands.questions.badges.duplicate') }}
|
|
|
|
|
|
</a-tag>
|
|
|
|
|
|
<a-tag v-else-if="row.too_short" color="red">
|
|
|
|
|
|
{{ t('brands.questions.badges.tooShort') }}
|
|
|
|
|
|
</a-tag>
|
|
|
|
|
|
<div class="candidate-row__actions">
|
|
|
|
|
|
<a-button type="text" shape="circle" @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', {
|
2026-05-13 15:59:39 +08:00
|
|
|
|
used: displayUsedQuestions,
|
2026-05-12 21:53:36 +08:00
|
|
|
|
total: maxQuestions || '--',
|
|
|
|
|
|
})
|
|
|
|
|
|
}}
|
2026-05-13 15:59:39 +08:00
|
|
|
|
<span>{{ t('brands.questions.remaining', { count: displayRemainingQuestions }) }}</span>
|
2026-05-12 21:53:36 +08:00
|
|
|
|
</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>
|
2026-05-13 15:59:39 +08:00
|
|
|
|
|
|
|
|
|
|
<AiWaitingModal
|
|
|
|
|
|
:open="aiCandidateModalOpen"
|
|
|
|
|
|
:title="aiWaitingTitle"
|
|
|
|
|
|
:progress="aiCandidateProgress"
|
|
|
|
|
|
:stage="aiCandidateStages[aiCandidateStageIndex]"
|
|
|
|
|
|
/>
|
2026-05-12 21:53:36 +08:00
|
|
|
|
</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: ':';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 15:59:39 +08:00
|
|
|
|
.tool-header-actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tool-ai-fill {
|
|
|
|
|
|
background: linear-gradient(135deg, #1677ff, #722ed1) !important;
|
|
|
|
|
|
border: none !important;
|
|
|
|
|
|
color: #ffffff !important;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
box-shadow: 0 4px 12px rgba(114, 46, 209, 0.25);
|
|
|
|
|
|
transition: all 0.3s ease;
|
|
|
|
|
|
padding: 0 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tool-ai-fill:hover {
|
|
|
|
|
|
background: linear-gradient(135deg, #2563eb, #8b5cf6) !important;
|
|
|
|
|
|
box-shadow: 0 6px 16px rgba(114, 46, 209, 0.4);
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 21:53:36 +08:00
|
|
|
|
.tool-estimate {
|
|
|
|
|
|
min-width: 168px;
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.wizard-ai-banner {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
padding: 16px 20px;
|
|
|
|
|
|
background: linear-gradient(135deg, rgba(22, 119, 255, 0.04), rgba(22, 119, 255, 0.08));
|
|
|
|
|
|
border: 1px solid rgba(22, 119, 255, 0.15);
|
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
|
box-shadow: 0 4px 16px rgba(22, 119, 255, 0.05);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.wizard-ai-banner__info {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.wizard-ai-banner__icon {
|
|
|
|
|
|
font-size: 28px;
|
|
|
|
|
|
color: #1677ff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.wizard-ai-banner__text h4 {
|
|
|
|
|
|
margin: 0 0 4px;
|
|
|
|
|
|
color: #1f2f4d;
|
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.wizard-ai-banner__text p {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
color: #6b7a99;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.candidate-list {
|
2026-05-13 15:59:39 +08:00
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
align-items: stretch;
|
|
|
|
|
|
padding: 12px;
|
|
|
|
|
|
background: #f8fafc;
|
2026-05-12 21:53:36 +08:00
|
|
|
|
border: 1px solid #e2e8f0;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.candidate-empty {
|
2026-05-13 15:59:39 +08:00
|
|
|
|
grid-column: 1 / -1;
|
2026-05-12 21:53:36 +08:00
|
|
|
|
padding: 32px;
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
text-align: center;
|
2026-05-13 15:59:39 +08:00
|
|
|
|
background: #ffffff;
|
|
|
|
|
|
border: 1px dashed #d8e0ec;
|
|
|
|
|
|
border-radius: 8px;
|
2026-05-12 21:53:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.candidate-row {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: 24px minmax(0, 1fr) auto auto;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
align-items: center;
|
2026-05-13 15:59:39 +08:00
|
|
|
|
min-height: 58px;
|
2026-05-12 21:53:36 +08:00
|
|
|
|
padding: 12px 16px;
|
2026-05-13 15:59:39 +08:00
|
|
|
|
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;
|
2026-05-12 21:53:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.candidate-row:hover {
|
2026-05-13 15:59:39 +08:00
|
|
|
|
background-color: #fbfdff;
|
|
|
|
|
|
border-color: #b7d6ff;
|
|
|
|
|
|
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.04);
|
2026-05-12 21:53:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.candidate-row__text {
|
|
|
|
|
|
padding: 4px 11px;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
color: #1e293b;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
|
word-break: break-all;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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 {
|
2026-05-13 15:59:39 +08:00
|
|
|
|
grid-template-columns: 24px minmax(0, 1fr) auto auto;
|
2026-05-12 21:53:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.wizard-page__footer {
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
align-items: stretch;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.footer-actions {
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|