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

1339 lines
37 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import {
DeleteOutlined,
EditOutlined,
LeftOutlined,
RobotOutlined,
SaveOutlined,
ThunderboltOutlined,
} from '@ant-design/icons-vue'
import { ApiClientError } from '@geo/http-client'
import type {
BrandLibrarySummary,
QuestionCandidate,
QuestionSource,
} from '@geo/shared-types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { message } from 'ant-design-vue'
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
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('')
const vFocus = {
mounted(el: HTMLElement) {
const input = el.tagName === 'INPUT' ? el : el.querySelector('input')
if (input) {
input.focus()
const len = input.value.length
input.setSelectionRange(len, len)
}
},
}
const expansionForm = reactive({
region: ['合肥', '上海', '华东', ''],
prefix: ['好用的', '专业的', '靠谱的', ''],
core: ['GEO', 'AI搜索优化', ''],
industry: ['工具', '平台', '服务商', ''],
suffix: ['哪家好', '怎么选', '推荐', ''],
})
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)
})
const questionLimitReached = computed(() => {
if (!maxQuestions.value) {
return false
}
return usedQuestions.value >= maxQuestions.value
})
const existingQuestionSet = computed(() => {
const keys = new Set<string>()
for (const question of currentQuestions.value) {
keys.add(normalizeQuestionKey(question.question_text))
}
return keys
})
const selectedCandidates = computed(() => candidateRows.value.filter((item) => item.selected))
const candidateStats = computed(() => {
const total = candidateRows.value.length
const selected = selectedCandidates.value.length
const tooShort = candidateRows.value.filter((item) => item.too_short).length
const duplicate = candidateRows.value.filter((item) => item.duplicate).length
return { total, selected, tooShort, duplicate }
})
const expansionToolTotal = computed(() => {
const core = splitLines(expansionForm.core).length
const industry = splitLines(expansionForm.industry).length
if (!core || !industry) {
return 0
}
return (
combinationPartCount(expansionForm.region) *
combinationPartCount(expansionForm.prefix) *
core *
industry *
combinationPartCount(expansionForm.suffix)
)
})
const expansionToolPreviewCount = computed(() => Math.min(expansionToolTotal.value, 500))
const createModeSource = computed<QuestionSource>(() => {
if (createMode.value === 'ai') {
return 'ai_distill'
}
if (createMode.value === 'batch') {
return 'manual'
}
return 'combination'
})
const isPageLoading = computed(
() =>
brandQuery.isPending.value ||
brandLibrarySummaryQuery.isPending.value ||
questionsQuery.isPending.value,
)
const isGenerating = computed(
() => previewMutations.combination.isPending.value || previewMutations.ai.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: () =>
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)
},
}),
}
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] = ''
}
}
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}`,
selected: !candidate.suggest_skip,
}))
}
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,
selected: !(tooShort || duplicate || invalid),
})
}
return rows
}
function changeCreateMode(mode: CreateMode): void {
if (createMode.value === mode) {
return
}
createMode.value = mode
currentStep.value = 0
candidateRows.value = []
pageNotice.value = ''
}
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
}
await previewMutations.ai.mutateAsync()
}
function removeCandidate(id: string): void {
candidateRows.value = candidateRows.value.filter((item) => item.id !== id)
}
function refreshCandidate(row: CandidateRow): void {
const wasSkipped = row.suggest_skip
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)
if (row.suggest_skip) {
row.selected = false
} else if (wasSkipped) {
row.selected = true
}
}
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 (row.suggest_skip && checked) {
pageNotice.value = t('brands.questions.errors.invalidCandidate')
return
}
row.selected = checked
}
async function saveCandidates(): Promise<void> {
refreshAllCandidates()
if (!selectedCandidates.value.length) {
pageNotice.value = t('brands.questions.errors.noSelection')
return
}
await materializeMutation.mutateAsync()
}
function backToBrands(): void {
void router.push({
name: 'brands',
query: brandId.value ? { brand_id: String(brandId.value) } : undefined,
})
}
</script>
<template>
<div class="wizard-page">
<div v-if="isPageLoading" class="wizard-page__loading">
<a-skeleton active :paragraph="{ rows: 12 }" />
</div>
<template v-else>
<header class="wizard-page__header">
<div class="header-left">
<a-button type="text" shape="circle" @click="backToBrands">
<template #icon><LeftOutlined /></template>
</a-button>
<div class="header-title-box">
<h2 class="header-title">
{{ t('brands.questions.page.title') }}
<span class="header-accent">{{ brandQuery.data.value?.name }}</span>
</h2>
<p class="header-eyebrow">{{ t('brands.questions.page.subtitle') }}</p>
</div>
</div>
</header>
<main class="wizard-page__main">
<div class="wizard-steps-container">
<a-steps :current="currentStep" class="wizard-steps" size="small">
<a-step :title="t('brands.questions.page.steps.configure')" />
<a-step :title="t('brands.questions.page.steps.preview')" />
</a-steps>
</div>
<a-alert
v-if="pageNotice"
class="page-alert"
type="warning"
show-icon
:message="pageNotice"
/>
<section v-if="currentStep === 0" class="wizard-section">
<div class="wizard-card">
<div class="wizard-card__header">
<div>
<h3>{{ t('brands.questions.page.methodTitle') }}</h3>
<p>{{ t('brands.questions.page.methodHint') }}</p>
</div>
</div>
<div class="method-grid">
<button
type="button"
class="method-card"
:class="{ 'method-card--active': createMode === 'combination' }"
@click="changeCreateMode('combination')"
>
<strong>{{ t('brands.questions.modal.methods.combination') }}</strong>
<span>{{ t('brands.questions.page.methods.combination') }}</span>
</button>
<button
type="button"
class="method-card"
:class="{ 'method-card--active': createMode === 'ai' }"
@click="changeCreateMode('ai')"
>
<strong>{{ t('brands.questions.modal.methods.aiDistill') }}</strong>
<span>{{ t('brands.questions.page.methods.ai') }}</span>
</button>
<button
type="button"
class="method-card"
:class="{ 'method-card--active': createMode === 'batch' }"
@click="changeCreateMode('batch')"
>
<strong>{{ t('brands.questions.modal.methods.manualBatch') }}</strong>
<span>{{ t('brands.questions.page.methods.batch') }}</span>
</button>
</div>
</div>
<div v-if="createMode === 'combination'" class="wizard-card">
<div class="wizard-card__header">
<div>
<h3>{{ t('brands.questions.modal.methods.combination') }}</h3>
<p>{{ t('brands.questions.page.expansionHint') }}</p>
</div>
<div class="tool-estimate">
{{ t('brands.questions.combination.estimate', { count: expansionToolPreviewCount }) }}
</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"
:class="{ 'candidate-row--muted': row.suggest_skip }"
>
<a-checkbox
:checked="row.selected"
@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', {
used: usedQuestions,
total: maxQuestions || '--',
})
}}
<span>{{ t('brands.questions.remaining', { count: remainingQuestions }) }}</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>
</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: '';
}
.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 {
display: flex;
flex-direction: column;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.candidate-empty {
padding: 32px;
color: #94a3b8;
text-align: center;
background: #f8fafc;
}
.candidate-row {
display: grid;
grid-template-columns: 24px minmax(0, 1fr) auto auto;
gap: 12px;
align-items: center;
padding: 12px 16px;
background: transparent;
border-bottom: 1px solid #f1f5f9;
transition: background-color 0.2s ease;
}
.candidate-row:last-child {
border-bottom: none;
}
.candidate-row:hover {
background-color: #f8fafc;
}
.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;
}
.candidate-row--muted {
background: #f8fafc;
color: #94a3b8;
}
.candidate-row--muted .candidate-row__text {
color: #94a3b8;
}
.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;
}
.wizard-page__footer {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.footer-actions {
justify-content: flex-end;
}
}
</style>