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