|
- <template>
- <div class="min-h-screen bg-[var(--c-f3f7fb)]">
- <!-- ========== Hero (统一使用 AppHero,保证各页高度/底色一致) ========== -->
- <AppHero
- kicker="AI NAME STUDIO"
- title="给宝宝取一个有出处的好名字"
- desc="结合姓氏、性别、诗词典故和音律寓意,生成可直接挑选的名字方案。"
- >
- <template #actions>
- <button
- type="button"
- class="inline-flex h-10 items-center gap-1.5 rounded-full bg-[var(--c-ffffff)] px-5 text-sm font-extrabold text-[var(--c-0b8cff)] shadow-sm transition-colors hover:bg-[var(--c-f7faff)]"
- @click="historyOpen = true"
- >
- <UIcon name="i-lucide-history" class="size-4" />
- 历史记录
- </button>
- </template>
- <template #aside>
- <div class="hidden w-[260px] lg:block">
- <div class="rounded-3xl border border-[var(--c-e8eef7)] bg-[var(--c-ffffff)]/80 p-5 shadow-[0_18px_34px_var(--sh-0-92-190-100)] backdrop-blur">
- <div class="text-3xl font-extrabold text-[var(--c-0b8cff)]">{{ previewName }}</div>
- <div class="mt-1.5 text-xs text-[var(--c-8a97a8)]">李白《独坐敬亭山》</div>
- </div>
- <div class="mt-3 flex h-12 items-center justify-between rounded-2xl border border-[var(--c-e8eef7)] bg-[var(--c-ffffff)]/70 px-4 text-xs font-bold text-[var(--c-66758a)] backdrop-blur">
- <span>音律</span><span>寓意</span><span>出处</span>
- </div>
- </div>
- </template>
- </AppHero>
-
- <!-- ========== Main Grid ========== -->
- <div class="max-w-7xl mx-auto px-4 sm:px-6 py-6 lg:py-8">
- <div class="lg:grid lg:grid-cols-12 lg:gap-8">
- <!-- ===== LEFT: Form (col-span-7) ===== -->
- <div class="lg:col-span-7 space-y-5 pb-24 lg:pb-0">
- <!-- 1. 基础信息 Card -->
- <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_8px_28px_var(--sh-34-68-112-50)]">
- <div class="flex items-baseline mb-4">
- <h3 class="text-base lg:text-lg font-extrabold text-[var(--c-172033)]">基础信息</h3>
- <span class="ml-auto text-xs text-[var(--c-8a95a6)]">必填</span>
- </div>
- <!-- Surname + Gender (一行,对齐 V2 surname-box) -->
- <div class="flex items-stretch gap-3 rounded-2xl bg-[var(--c-f6f9fe)] p-3.5">
- <div class="flex-1 min-w-0">
- <label class="block text-sm font-bold text-[var(--c-172033)]">姓氏</label>
- <input v-model="form.surname" type="text" maxlength="5" placeholder="输入姓氏"
- class="w-full mt-1 bg-transparent text-xl lg:text-2xl font-extrabold text-[var(--c-172033)] placeholder:text-[var(--c-a0aabb)] focus:outline-none" />
- </div>
- <div class="w-[180px] flex-shrink-0 p-1.5 rounded-[28px] bg-[var(--c-ffffff)] shadow-[inset_0_0_0_1px_var(--c-edf2f7)] flex">
- <button v-for="opt in genderOptions" :key="opt.value" type="button"
- class="flex-1 h-10 rounded-[24px] text-sm font-bold transition-all"
- :class="form.sex === opt.value ? 'bg-[var(--c-0b8cff)] text-white' : 'text-[var(--c-7f8896)]'"
- @click="form.sex = opt.value">{{ opt.label }}</button>
- </div>
- </div>
-
- <!-- 出生信息开关 -->
- <div class="mt-4 rounded-2xl border-2 transition-colors"
- :class="birthInfoIgnored ? 'bg-[var(--c-f7f9fd)] border-[var(--c-edf2f7)]' : 'bg-[var(--c-eff8ff)] border-[var(--c-0b8cff)]/20'">
- <div class="flex items-center p-4">
- <div class="flex-1 min-w-0">
- <div class="text-sm font-extrabold text-[var(--c-172033)]">参考出生信息</div>
- <div class="mt-1 text-xs text-[var(--c-7f8896)]">{{ birthInfoIgnored ? '已关闭,生成时不参考农历和五行' : '开启后会结合农历和五行倾向' }}</div>
- </div>
- <button
- type="button"
- class="ml-3 h-7 w-[60px] flex-shrink-0 rounded-full p-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2"
- :class="!birthInfoIgnored ? 'bg-[var(--c-0b8cff)]' : 'bg-[var(--c-d7deea)]'"
- :aria-pressed="!birthInfoIgnored"
- aria-label="切换参考出生信息"
- @click="toggleBirthInfo"
- >
- <div class="w-5 h-5 rounded-full bg-[var(--c-ffffff)] shadow transition-transform"
- :class="!birthInfoIgnored && 'translate-x-7'" />
- </button>
- </div>
- <div v-if="!birthInfoIgnored" class="px-4 pb-4 space-y-3">
- <div class="grid grid-cols-2 gap-3">
- <div class="rounded-2xl bg-[var(--c-f6f9fe)] p-4 flex items-start justify-between cursor-pointer min-h-[88px]"
- @click="openDatePicker">
- <div class="min-w-0">
- <div class="text-sm font-bold text-[var(--c-172033)]">出生日期</div>
- <div class="mt-2 text-sm text-[var(--c-7f8896)]">{{ birthDate || '请选择日期' }}</div>
- </div>
- <span class="ml-2 text-2xl text-[var(--c-a0aabb)] leading-none flex-shrink-0">›</span>
- </div>
- <div class="rounded-2xl bg-[var(--c-f6f9fe)] p-4 flex items-start justify-between cursor-pointer min-h-[88px]"
- :class="{ 'opacity-80': birthTimeUnknown }" @click="openTimePicker">
- <div class="min-w-0">
- <div class="text-sm font-bold text-[var(--c-172033)]">出生时分</div>
- <div class="mt-2 text-sm text-[var(--c-7f8896)]">{{ birthClockText }}</div>
- </div>
- <span class="ml-2 text-2xl text-[var(--c-a0aabb)] leading-none flex-shrink-0">›</span>
- </div>
- </div>
- <!-- 农历显示 -->
- <div v-if="birthLunarText" class="rounded-2xl bg-[var(--c-eff8ff)] p-4 flex items-center">
- <span class="mr-3 px-3 py-0.5 rounded-full bg-[var(--c-ffffff)] text-[var(--c-0b8cff)] text-xs font-extrabold flex-shrink-0">农历</span>
- <span class="text-sm font-bold text-[var(--c-3a5878)] min-w-0">{{ birthLunarText }}</span>
- </div>
- <!-- 不知道具体时间 -->
- <div class="inline-flex items-center rounded-full px-4 py-2 cursor-pointer transition-colors border-2"
- :class="birthTimeUnknown ? 'bg-[var(--c-eff8ff)] border-[var(--c-0b8cff)] text-[var(--c-0b8cff)]' : 'bg-[var(--c-f6f9fe)] border-transparent text-[var(--c-7f8896)]'"
- @click="toggleBirthTimeUnknown">
- <span class="w-1.5 h-1.5 mr-2 rounded-full bg-current" />
- <span class="text-xs font-bold">不知道具体时间</span>
- </div>
- </div>
- </div>
- </div>
-
- <!-- 2. 名字出处 Card -->
- <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_8px_28px_var(--sh-34-68-112-50)]">
- <div class="flex items-baseline">
- <h3 class="text-base lg:text-lg font-extrabold text-[var(--c-172033)]">名字出处</h3>
- <span class="ml-auto text-xs text-[var(--c-8a95a6)]">选择偏好的文化来源</span>
- </div>
- <div class="mt-4 grid grid-cols-2 lg:grid-cols-3 gap-2.5">
- <button v-for="src in visibleSourceOptions" :key="src.id" type="button"
- class="min-h-[72px] p-3 rounded-2xl border-2 transition-all text-left"
- :class="form.name_source === src.id
- ? 'bg-[var(--c-eff8ff)] border-[var(--c-0b8cff)] shadow-[0_8px_20px_var(--sh-11-140-255-100)]'
- : 'bg-[var(--c-f7f9fd)] border-transparent hover:border-[var(--c-e7edf5)]'"
- @click="form.name_source = src.id">
- <div class="text-sm font-extrabold text-[var(--c-172033)] text-center">{{ src.label }}</div>
- <div class="mt-1 text-[11px] text-[var(--c-8a95a6)] text-center">{{ src.desc }}</div>
- </button>
- <button type="button"
- class="min-h-[72px] p-3 rounded-2xl border-2 bg-[var(--c-ffffff)] border-[var(--c-e7edf5)] hover:border-[var(--c-0b8cff)]/30 transition-all text-left"
- @click="sourceExpanded = !sourceExpanded">
- <div class="text-sm font-extrabold text-[var(--c-172033)] text-center">{{ sourceExpanded ? '收起' : '更多' }}</div>
- <div class="mt-1 text-[11px] text-[var(--c-8a95a6)] text-center">{{ sourceExpanded ? '精简显示' : '展开全部' }}</div>
- </button>
- </div>
- </div>
-
- <!-- 3. 名字偏好 Card -->
- <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_8px_28px_var(--sh-34-68-112-50)]">
- <div class="flex items-baseline">
- <h3 class="text-base lg:text-lg font-extrabold text-[var(--c-172033)]">名字偏好</h3>
- <span class="ml-auto text-xs text-[var(--c-8a95a6)]">用于控制生成结果的方向</span>
- </div>
-
- <!-- 字数 -->
- <div class="mt-5">
- <div class="text-sm font-bold text-[var(--c-172033)]">名字字数 <span class="text-xs font-normal text-[var(--c-8a95a6)]">包含姓氏</span></div>
- <div class="mt-3 flex flex-wrap gap-2.5">
- <button v-for="wc in wordOptions" :key="wc.id" type="button"
- class="h-10 px-5 rounded-full text-sm font-bold transition-all border-2"
- :class="form.words_num === wc.id ? 'bg-[var(--c-eff8ff)] border-[var(--c-0b8cff)] text-[var(--c-0b8cff)]' : 'bg-[var(--c-f6f9fe)] border-transparent text-[var(--c-596579)]'"
- @click="form.words_num = wc.id">{{ wc.label }}</button>
- </div>
- </div>
-
- <!-- 是否叠词 -->
- <div class="mt-5">
- <div class="text-sm font-bold text-[var(--c-172033)]">是否叠词</div>
- <div class="mt-3 flex flex-wrap gap-2.5">
- <button v-for="r in redupOptions" :key="r.id" type="button"
- class="h-10 px-5 rounded-full text-sm font-bold transition-all border-2"
- :class="form.is_redup === r.id ? 'bg-[var(--c-eff8ff)] border-[var(--c-0b8cff)] text-[var(--c-0b8cff)]' : 'bg-[var(--c-f6f9fe)] border-transparent text-[var(--c-596579)]'"
- @click="form.is_redup = r.id">{{ r.label }}</button>
- </div>
- </div>
-
- <!-- 指定包含字 -->
- <div class="mt-5 flex items-center rounded-2xl bg-[var(--c-f7f9fd)] p-4">
- <div class="min-w-0">
- <div class="text-sm font-bold text-[var(--c-172033)]">指定包含字</div>
- <div class="mt-1 text-xs text-[var(--c-8a95a6)]">可选,限制 1 个字</div>
- </div>
- <input v-model="form.special_word" type="text" maxlength="1" placeholder="如:辰"
- class="ml-auto w-24 h-12 rounded-2xl bg-[var(--c-ffffff)] text-center text-lg font-extrabold text-[var(--c-172033)] placeholder:text-[var(--c-a0aabb)] focus:outline-none focus:ring-2 focus:ring-[var(--c-0b8cff)]/30" />
- </div>
-
- <!-- 补充描述 -->
- <div class="mt-4 rounded-2xl bg-[var(--c-f7f9fd)] p-4">
- <div class="flex items-start justify-between gap-4">
- <div>
- <div class="text-sm font-bold text-[var(--c-172033)]">补充描述</div>
- <div class="mt-1 text-xs text-[var(--c-8a95a6)]">额外要求、寓意和期望</div>
- </div>
- <div class="text-xs text-[var(--c-9aa5b5)] flex-shrink-0">{{ (form.content || '').length }}/200</div>
- </div>
- <textarea v-model="form.content" rows="3" maxlength="200" placeholder="您可以补充您的额外要求,也可以添加您的寓意和期望。200字以内..."
- class="mt-3 w-full bg-transparent text-sm text-[var(--c-172033)] placeholder:text-[var(--c-a0aabb)] focus:outline-none resize-none leading-relaxed"></textarea>
- </div>
- </div>
-
- <!-- 4. Promise Card -->
- <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_8px_28px_var(--sh-34-68-112-50)] space-y-4">
- <div class="flex items-center">
- <span class="w-7 h-5 mr-3 rounded-[14px] bg-[var(--c-eef6ff)] text-[var(--c-0b8cff)] text-[11px] font-extrabold flex items-center justify-center flex-shrink-0">01</span>
- <span class="text-sm text-[var(--c-596579)] leading-relaxed">可关闭出生信息参考,关闭后不结合农历和五行</span>
- </div>
- <div class="flex items-center">
- <span class="w-7 h-5 mr-3 rounded-[14px] bg-[var(--c-eef6ff)] text-[var(--c-0b8cff)] text-[11px] font-extrabold flex items-center justify-center flex-shrink-0">02</span>
- <span class="text-sm text-[var(--c-596579)] leading-relaxed">开启后可选择日期和时分,不确定时辰可选"不知道具体时间"</span>
- </div>
- </div>
-
- <!-- Desktop Submit -->
- <div class="hidden lg:flex items-center gap-3 rounded-2xl bg-[var(--c-ffffff)] p-4 shadow-[0_8px_28px_var(--sh-34-68-112-50)]">
- <div class="flex-1 min-w-0">
- <div class="text-sm font-bold text-[var(--c-172033)] whitespace-nowrap">{{ balanceText }}</div>
- <div v-if="isBalanceInsufficient" class="mt-1 text-xs text-[var(--c-f04438)]">点数不足,请前往充值</div>
- </div>
- <button type="button"
- class="h-14 flex-shrink-0 rounded-full border border-[var(--c-e5edf7)] px-6 text-sm font-bold text-[var(--c-5b6b80)] transition-colors hover:bg-[var(--c-f7f9fd)] flex items-center gap-1.5"
- @click="historyOpen = true">
- <UIcon name="i-lucide-history" class="size-4" />
- 历史记录
- </button>
- <button type="button"
- class="px-10 h-14 rounded-full bg-[var(--c-0b8cff)] hover:bg-[var(--c-0a7ce0)] active:bg-[var(--c-096dca)] text-white font-bold text-base shadow-[0_12px_24px_var(--sh-11-140-255-250)] transition-all flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center min-w-[160px]"
- :disabled="!canSubmit || submitting" @click="handleSubmit">
- <UIcon v-if="!submitting" name="i-lucide-zap" class="size-4 mr-1.5" />
- <span>{{ submitting ? '生成中...' : submitText }}</span>
- </button>
- </div>
- </div>
-
- <!-- ===== RIGHT: Result (col-span-5, sticky) ===== -->
- <div class="lg:col-span-5">
- <!-- 吸顶偏移 = header 高度(64px) + 间距,避免被导航遮挡 -->
- <div class="space-y-4 lg:sticky lg:top-20 lg:max-h-[calc(100vh-6rem)] lg:overflow-y-auto">
- <div v-if="!resultState.visible" class="bg-[var(--c-ffffff)] rounded-2xl p-8 lg:p-10 shadow-sm text-center min-h-[300px] flex flex-col items-center justify-center">
- <UIcon name="i-lucide-sparkles" class="size-12 text-[var(--c-a0aabb)] mb-4" />
- <div class="text-base font-medium text-[var(--c-172033)] mb-1">等待生成</div>
- <div class="text-sm text-[var(--c-a0aabb)]">填写左侧信息并点击"开始取名"</div>
- <div class="text-sm text-[var(--c-a0aabb)]">AI将为您推荐最佳名字方案</div>
- </div>
- <div v-if="resultState.loading" class="bg-[var(--c-ffffff)] rounded-2xl p-6 lg:p-8 shadow-sm">
- <div class="text-base font-bold text-[var(--c-172033)] mb-1">{{ questionTitle }}</div>
- <div class="text-sm text-[var(--c-7f8896)] mb-6">AI 正在分析音律、寓意和出处,内容将实时显示...</div>
- <div v-if="streamContent" ref="streamPreviewRef" class="max-h-[420px] overflow-y-auto rounded-2xl bg-[var(--c-f7f9fd)] p-4 text-sm leading-7 text-[var(--c-334155)] whitespace-pre-wrap break-words">
- {{ streamContent }}<span class="ml-1 inline-block h-4 w-1.5 animate-pulse rounded-sm bg-[var(--c-0b8cff)] align-middle" />
- </div>
- <div v-else class="rounded-2xl bg-[var(--c-f7f9fd)] py-10 flex flex-col items-center">
- <div class="flex gap-1.5 mb-4">
- <span class="w-2.5 h-2.5 rounded-full bg-[var(--c-0b8cff)] animate-pulse-dot" />
- <span class="w-2.5 h-2.5 rounded-full bg-[var(--c-0b8cff)] animate-pulse-dot2" />
- <span class="w-2.5 h-2.5 rounded-full bg-[var(--c-0b8cff)] animate-pulse-dot3" />
- </div>
- <div class="text-sm font-bold text-[var(--c-172033)]">正在整理名字方案</div>
- </div>
- </div>
- <div v-if="resultState.error" class="bg-[var(--c-ffffff)] rounded-2xl p-6 lg:p-8 shadow-sm text-center py-10">
- <UIcon name="i-lucide-circle-alert" class="size-12 text-red-400 mb-3 mx-auto" />
- <div class="text-base font-medium text-[var(--c-f04438)] mb-2">生成失败</div>
- <div class="text-sm text-[var(--c-7f8896)] mb-4">{{ resultState.errorMsg }}</div>
- <UButton color="primary" variant="outline" size="sm" @click="handleSubmit">重试</UButton>
- </div>
- <div v-if="resultState.empty" class="bg-[var(--c-ffffff)] rounded-2xl p-6 lg:p-8 shadow-sm text-center py-10">
- <UIcon name="i-lucide-search-x" class="size-12 text-[var(--c-a0aabb)] mb-3 mx-auto" />
- <div class="text-base font-bold text-[var(--c-172033)] mb-1">本次没有可用名字</div>
- <div class="text-sm text-[var(--c-7f8896)] max-w-xs mx-auto">{{ result.summary || 'AI 返回的候选名未通过姓名可用性筛选,请调整条件或重新生成。' }}</div>
- <UButton color="primary" size="sm" class="mt-5" @click="handleSubmit">重新生成</UButton>
- </div>
- <template v-if="resultState.success">
- <div v-if="result.summary" class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-sm">
- <div class="text-sm text-[var(--c-596579)] leading-relaxed">{{ result.summary }}</div>
- </div>
- <div v-for="(item, idx) in result.names" :key="idx" class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-sm">
- <div class="flex items-center justify-between mb-2">
- <span class="text-2xl lg:text-3xl font-bold text-[var(--c-0b8cff)]">{{ item.name }}</span>
- <span v-if="item.score" class="text-lg font-bold text-[var(--c-ff8a00)]">{{ item.score }}分</span>
- </div>
- <div v-if="item.pinyin" class="text-xs text-[var(--c-7f8896)] mb-3">{{ item.pinyin }}</div>
- <div class="space-y-2.5">
- <div v-for="field in nameFields" :key="field.key">
- <div v-if="item[field.key]" class="flex items-start gap-2.5">
- <span class="flex-shrink-0 inline-flex items-center justify-center h-[26px] px-2 rounded-full bg-[var(--c-eef6ff)] text-[var(--c-0b8cff)] text-[11px] font-medium">{{ field.label }}</span>
- <span class="text-sm text-[var(--c-172033)] leading-relaxed pt-0.5">{{ item[field.key] }}</span>
- </div>
- </div>
- </div>
- </div>
- <div class="flex gap-3">
- <button
- type="button"
- class="flex h-12 flex-1 items-center justify-center gap-2 rounded-full border border-[var(--c-0b8cff)] bg-[var(--c-ffffff)] px-5 text-sm font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-eef7ff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
- :disabled="submitting"
- @click="handleSubmit"
- >
- <UIcon v-if="submitting" name="i-lucide-loader-circle" class="size-4 animate-spin" />
- <UIcon v-else name="i-lucide-refresh-cw" class="size-4" />
- {{ submitting ? '生成中...' : '重新生成' }}
- </button>
- <button
- type="button"
- class="flex h-12 flex-1 items-center justify-center gap-2 rounded-full bg-[var(--c-0b8cff)] px-5 text-sm font-bold text-white shadow-[0_10px_22px_var(--sh-11-140-255-200)] transition-colors hover:bg-[var(--c-0a7ce0)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2"
- @click="copyResult"
- >
- <UIcon name="i-lucide-copy" class="size-4" />
- 复制结果
- </button>
- </div>
- </template>
- </div>
- </div>
- </div>
- </div>
-
- <!-- ========== Mobile Bottom Bar (fixed) ========== -->
- <div class="lg:hidden fixed left-0 right-0 bottom-0 z-50 bg-[var(--c-ffffff)]/95 backdrop-blur shadow-[0_-8px_24px_var(--sh-35-55-90-80)]"
- style="padding: 14px 16px calc(14px + env(safe-area-inset-bottom))">
- <div class="flex items-center justify-between mb-2.5 gap-3 text-sm">
- <div class="min-w-0 truncate text-[var(--c-7b8797)]">{{ balanceText }}</div>
- <button v-if="isBalanceInsufficient" class="text-[var(--c-0b8cff)] text-xs font-extrabold flex-shrink-0" @click="goRecharge">去充值</button>
- </div>
- <div class="flex gap-3">
- <button class="w-[120px] h-12 rounded-full bg-[var(--c-eef6ff)] text-[var(--c-0b8cff)] text-sm font-bold" @click="goHistory">历史记录</button>
- <button class="flex-1 h-12 rounded-full text-white font-bold shadow-[0_12px_24px_var(--sh-11-140-255-200)] transition-opacity"
- :class="(canSubmit && !submitting) ? 'bg-[var(--c-0b8cff)]' : 'bg-[var(--c-0b8cff)] opacity-60'"
- :disabled="!canSubmit || submitting" @click="handleSubmit">
- {{ submitting ? '生成中...' : submitText }}
- </button>
- </div>
- </div>
-
- <!-- ========== 历史记录抽屉 + 详情弹窗 ========== -->
- <WorkHistoryDrawer v-model="historyOpen" type="ainame" @select="openHistoryItem" />
- <WorkDetailModal v-model="detailOpen" :work="currentWork" />
-
- <!-- ========== Date Picker Modal (solar + lunar calendar) ========== -->
- <Teleport to="body">
- <Transition name="modal">
- <div v-if="dateOpen" class="fixed inset-0 z-[100] flex items-end justify-center sm:items-center sm:p-4">
- <div class="absolute inset-0 bg-black/50" @click="dateOpen = false" />
- <div
- class="relative flex max-h-[92dvh] w-full max-w-md flex-col overflow-hidden rounded-t-3xl bg-[var(--c-ffffff)] shadow-2xl sm:rounded-2xl"
- role="dialog"
- aria-modal="true"
- aria-labelledby="birth-date-dialog-title"
- >
- <div class="flex items-center justify-between px-5 py-4 border-b border-[var(--c-f3f4f6)]">
- <h3 id="birth-date-dialog-title" class="text-base font-extrabold text-[var(--c-172033)]">选择出生日期</h3>
- <button
- type="button"
- class="flex size-10 items-center justify-center rounded-full text-[var(--c-8793a5)] transition-colors hover:bg-[var(--c-f3f4f6)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]"
- aria-label="关闭日期选择"
- @click="dateOpen = false"
- >
- <UIcon name="i-lucide-x" class="size-5" />
- </button>
- </div>
- <div class="overflow-y-auto px-4 py-4 sm:px-5">
- <div class="flex items-center justify-between mb-4">
- <button type="button" class="flex size-11 items-center justify-center rounded-full text-[var(--c-172033)] transition-colors hover:bg-[var(--c-f3f4f6)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] disabled:cursor-not-allowed disabled:opacity-30" aria-label="上一个月" :disabled="isPrevMonthDisabled" @click="prevMonth">
- <UIcon name="i-lucide-chevron-left" class="size-5" />
- </button>
- <div class="flex items-center gap-2" aria-label="选择年月">
- <label class="relative">
- <span class="sr-only">年份</span>
- <select
- v-model.number="calendarYear"
- class="h-10 appearance-none rounded-xl border border-[var(--c-dce4ee)] bg-[var(--c-ffffff)] py-0 pl-3 pr-8 text-sm font-extrabold text-[var(--c-172033)] outline-none transition focus:border-[var(--c-0b8cff)] focus:ring-2 focus:ring-[var(--c-0b8cff)]/15"
- >
- <option v-for="year in calendarYearOptions" :key="year" :value="year">{{ year }}年</option>
- </select>
- <UIcon name="i-lucide-chevron-down" class="pointer-events-none absolute right-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--c-8793a5)]" />
- </label>
- <label class="relative">
- <span class="sr-only">月份</span>
- <select
- v-model.number="calendarMonth"
- class="h-10 appearance-none rounded-xl border border-[var(--c-dce4ee)] bg-[var(--c-ffffff)] py-0 pl-3 pr-8 text-sm font-extrabold text-[var(--c-172033)] outline-none transition focus:border-[var(--c-0b8cff)] focus:ring-2 focus:ring-[var(--c-0b8cff)]/15"
- >
- <option
- v-for="month in calendarMonthOptions"
- :key="month"
- :value="month"
- >
- {{ month }}月
- </option>
- </select>
- <UIcon name="i-lucide-chevron-down" class="pointer-events-none absolute right-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--c-8793a5)]" />
- </label>
- </div>
- <button type="button" class="flex size-11 items-center justify-center rounded-full text-[var(--c-172033)] transition-colors hover:bg-[var(--c-f3f4f6)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] disabled:cursor-not-allowed disabled:opacity-30" aria-label="下一个月" :disabled="isNextMonthDisabled" @click="nextMonth">
- <UIcon name="i-lucide-chevron-right" class="size-5" />
- </button>
- </div>
- <div class="grid grid-cols-7 gap-1 text-center text-xs text-[var(--c-7f8896)] mb-2">
- <div v-for="dow in ['日', '一', '二', '三', '四', '五', '六']" :key="dow" class="h-8 flex items-center justify-center">{{ dow }}</div>
- </div>
- <div class="grid grid-cols-7 gap-1">
- <button v-for="day in calendarDays" :key="day.key" type="button"
- class="relative flex min-h-12 flex-col items-center justify-center gap-0.5 rounded-xl px-0.5 text-sm transition-colors focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]"
- :class="{
- 'text-[var(--c-cbd5e1)]': day.otherMonth && !day.isSelected,
- 'text-[var(--c-172033)] hover:bg-[var(--c-f3f4f6)]': !day.otherMonth && !day.isSelected && !day.isToday && !day.disabled,
- 'ring-1 ring-[var(--c-0b8cff)] text-[var(--c-0b8cff)] font-bold': day.isToday && !day.isSelected,
- 'bg-[var(--c-0b8cff)] text-white font-bold': day.isSelected,
- 'opacity-30 cursor-not-allowed': day.disabled,
- }"
- :disabled="day.disabled"
- :aria-label="day.ariaLabel"
- :aria-pressed="day.isSelected"
- @click="selectDay(day)">
- <span class="leading-none">{{ day.day }}</span>
- <span
- v-if="day.lunar"
- class="max-w-full truncate text-[10px] font-normal leading-none"
- :class="day.isSelected ? 'text-white/90' : (day.lunar.special ? 'text-[var(--c-f06b3c)]' : 'text-[var(--c-8b96a8)]')"
- >
- {{ day.lunar.label }}
- </span>
- </button>
- </div>
- <div class="mt-4 flex items-center justify-center gap-2 text-xs text-[var(--c-7f8896)]">
- <span>日期下方为农历</span>
- <span class="size-1 rounded-full bg-[var(--c-cbd5e1)]" />
- <span>{{ calendarRangeText }}</span>
- </div>
- </div>
- <div class="flex gap-3 px-5 py-4 border-t border-[var(--c-f3f4f6)]">
- <button type="button" class="h-12 flex-1 rounded-full border border-[var(--c-e5e7eb)] text-[var(--c-596579)] font-bold transition-colors hover:bg-[var(--c-f9fafb)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]" @click="dateOpen = false">取消</button>
- <button type="button" class="h-12 flex-1 rounded-full bg-[var(--c-0b8cff)] text-white font-bold transition-colors hover:bg-[var(--c-0a7ce0)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" :disabled="!pendingDay" @click="confirmDate">确定</button>
- </div>
- </div>
- </div>
- </Transition>
- </Teleport>
-
- <!-- ========== Time Picker Modal (24-hour segmented selector) ========== -->
- <Teleport to="body">
- <Transition name="modal">
- <div v-if="timeOpen" class="fixed inset-0 z-[100] flex items-end justify-center sm:items-center sm:p-4">
- <div class="absolute inset-0 bg-black/50" @click="timeOpen = false" />
- <div
- class="relative w-full max-w-md overflow-hidden rounded-t-3xl bg-[var(--c-ffffff)] shadow-2xl sm:rounded-2xl"
- role="dialog"
- aria-modal="true"
- aria-labelledby="birth-time-dialog-title"
- >
- <div class="flex items-center justify-between px-5 py-4 border-b border-[var(--c-f3f4f6)]">
- <div>
- <h3 id="birth-time-dialog-title" class="text-base font-extrabold text-[var(--c-172033)]">选择出生时分</h3>
- <p class="mt-1 text-xs text-[var(--c-8793a5)]">使用 24 小时制</p>
- </div>
- <button
- type="button"
- class="flex size-10 items-center justify-center rounded-full text-[var(--c-8793a5)] transition-colors hover:bg-[var(--c-f3f4f6)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]"
- aria-label="关闭时间选择"
- @click="timeOpen = false"
- >
- <UIcon name="i-lucide-x" class="size-5" />
- </button>
- </div>
- <div class="p-5 sm:p-6">
- <div class="mb-5 flex items-center justify-between rounded-2xl border border-[var(--c-dbeaff)] bg-[var(--c-f5faff)] px-4 py-3.5">
- <div>
- <div class="text-3xl font-black tabular-nums tracking-tight text-[var(--c-172033)]">{{ timePreviewText }}</div>
- <div class="mt-1 text-xs text-[var(--c-64748b)]">{{ shichenInfo.name }} · {{ shichenInfo.range }}</div>
- </div>
- <div class="flex size-11 items-center justify-center rounded-xl bg-[var(--c-ffffff)] text-[var(--c-0b8cff)] shadow-sm">
- <UIcon name="i-lucide-clock-3" class="size-5" />
- </div>
- </div>
-
- <div class="grid grid-cols-2 gap-3">
- <label>
- <span class="mb-2 block text-sm font-bold text-[var(--c-596579)]">小时</span>
- <span class="relative block">
- <select
- v-model.number="tempHour"
- class="h-14 w-full appearance-none rounded-xl border border-[var(--c-dce4ee)] bg-[var(--c-ffffff)] px-4 pr-10 text-base font-bold tabular-nums text-[var(--c-172033)] outline-none transition focus:border-[var(--c-0b8cff)] focus:ring-2 focus:ring-[var(--c-0b8cff)]/15"
- >
- <option v-for="option in hourOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
- </select>
- <UIcon name="i-lucide-chevron-down" class="pointer-events-none absolute right-3.5 top-1/2 size-5 -translate-y-1/2 text-[var(--c-8793a5)]" />
- </span>
- </label>
- <label>
- <span class="mb-2 block text-sm font-bold text-[var(--c-596579)]">分钟</span>
- <span class="relative block">
- <select
- v-model.number="tempMinute"
- class="h-14 w-full appearance-none rounded-xl border border-[var(--c-dce4ee)] bg-[var(--c-ffffff)] px-4 pr-10 text-base font-bold tabular-nums text-[var(--c-172033)] outline-none transition focus:border-[var(--c-0b8cff)] focus:ring-2 focus:ring-[var(--c-0b8cff)]/15"
- >
- <option v-for="option in minuteOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
- </select>
- <UIcon name="i-lucide-chevron-down" class="pointer-events-none absolute right-3.5 top-1/2 size-5 -translate-y-1/2 text-[var(--c-8793a5)]" />
- </span>
- </label>
- </div>
-
- <div class="mt-4">
- <div class="mb-2 text-xs font-medium text-[var(--c-8793a5)]">快捷选择分钟</div>
- <div class="grid grid-cols-4 gap-2">
- <button
- v-for="minute in quickMinuteOptions"
- :key="minute"
- type="button"
- class="h-11 rounded-xl border text-sm font-bold tabular-nums transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]"
- :class="tempMinute === minute ? 'border-[var(--c-0b8cff)] bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)]' : 'border-[var(--c-e2e8f0)] text-[var(--c-596579)] hover:bg-[var(--c-f9fafb)]'"
- @click="tempMinute = minute"
- >
- :{{ padTime(minute) }}
- </button>
- </div>
- </div>
-
- <button
- type="button"
- class="mt-5 flex min-h-12 w-full items-center justify-between rounded-xl bg-[var(--c-f6f8fb)] px-4 text-left transition-colors hover:bg-[var(--c-eef2f7)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]"
- @click="timeUnknownPick"
- >
- <span>
- <span class="block text-sm font-bold text-[var(--c-334155)]">不知道具体时间</span>
- <span class="mt-0.5 block text-xs text-[var(--c-8793a5)]">仍可继续生成,系统将不使用具体时辰</span>
- </span>
- <UIcon name="i-lucide-chevron-right" class="size-5 flex-none text-[var(--c-a0aabb)]" />
- </button>
- </div>
- <div class="flex gap-3 px-5 py-4 border-t border-[var(--c-f3f4f6)]">
- <button type="button" class="h-12 flex-1 rounded-full border border-[var(--c-e5e7eb)] text-[var(--c-596579)] font-bold transition-colors hover:bg-[var(--c-f9fafb)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)]" @click="timeOpen = false">取消</button>
- <button type="button" class="h-12 flex-1 rounded-full bg-[var(--c-0b8cff)] text-white font-bold transition-colors hover:bg-[var(--c-0a7ce0)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2" @click="confirmTime">确定</button>
- </div>
- </div>
- </div>
- </Transition>
- </Teleport>
- </div>
- </template>
-
- <script setup>
- import { ref, reactive, computed, watch, onMounted, nextTick } from 'vue'
-
- const _api = useApi()
- const api = _api // _api 是 { get, post, request },别名保持 api.post(...) 调用
-
- await useLegacyAppSeo({
- appKey: 'naming',
- fallbackTitle: 'AI取昵称-AI取名生成器-AI取名字大全免费查询',
- fallbackDescription: '一个好的名字应该简洁明了,易于记忆和识别,AI取名可以通过你对海量的数据模型,给你的宝宝或者宠物取一个非常有特色的名字。',
- fallbackKeywords: 'AI取名、AI取昵称、取名、取名字',
- canonicalPath: '/writing/naming/',
- })
-
- const user = useUser()
- const router = useRouter()
- const toast = useToast()
-
- function showToast(message, color = 'red') {
- toast.add({ title: message, color })
- }
-
- // ==================== Options (对齐 V2) ====================
- const genderOptions = [
- { label: '男孩', value: 1 },
- { label: '女孩', value: 2 },
- ]
- const sourceOptions = [
- { id: '0', label: '不限', desc: '综合推荐' },
- { id: '1', label: '诗经', desc: '温润雅致' },
- { id: '2', label: '楚辞', desc: '浪漫开阔' },
- { id: '3', label: '离骚', desc: '清朗有骨' },
- { id: '4', label: '周易', desc: '稳重含蓄' },
- { id: '5', label: '史记', desc: '大气有典' },
- { id: '6', label: '诗词歌赋', desc: '文采舒展' },
- { id: '7', label: '成语典故', desc: '寓意明确' },
- { id: '8', label: '佛教', desc: '清净平和' },
- { id: '9', label: '道教', desc: '自然洒脱' },
- { id: '10', label: '自然景观', desc: '明亮灵动' },
- ]
- const wordOptions = [
- { id: '0', label: '不限' }, { id: '2', label: '2字' }, { id: '3', label: '3字' },
- { id: '4', label: '4字' }, { id: '5', label: '5字' },
- ]
- const redupOptions = [
- { id: '0', label: '不限' }, { id: '1', label: '是' }, { id: '2', label: '否' },
- ]
- const nameFields = [
- { key: 'source', label: '出处' }, { key: 'wuxing', label: '五行' },
- { key: 'meaning', label: '寓意' }, { key: 'tone', label: '音律' }, { key: 'reason', label: '推荐' },
- ]
-
- // ==================== Form ====================
- const form = reactive({
- surname: '', sex: 0, name_source: '0', words_num: '0',
- is_redup: '0', special_word: '', content: '',
- birth_date: '', birth_clock: '', birth_time_unknown: 1,
- })
- const sourceExpanded = ref(false)
- const visibleSourceOptions = computed(() => {
- if (sourceExpanded.value) return sourceOptions
- const selected = sourceOptions.find(i => i.id === form.name_source)
- const rows = [sourceOptions[0]]
- if (selected && selected.id !== '0') rows.push(selected)
- sourceOptions.slice(1).forEach(i => { if (rows.length < 5 && !rows.some(r => r.id === i.id)) rows.push(i) })
- return rows
- })
-
- // ==================== Birth Info ====================
- const birthInfoIgnored = ref(false)
- const birthDate = ref('')
- const birthClock = ref('')
- const birthTimeUnknown = ref(true)
- const dateOpen = ref(false)
- const timeOpen = ref(false)
- const tempHour = ref(8)
- const tempMinute = ref(0)
- const quickMinuteOptions = [0, 15, 30, 45]
- const hourOptions = Array.from({ length: 24 }, (_, value) => ({ value, label: `${String(value).padStart(2, '0')} 时` }))
- const minuteOptions = Array.from({ length: 60 }, (_, value) => ({ value, label: `${String(value).padStart(2, '0')} 分` }))
-
- // Custom calendar grid
- const todayDate = new Date()
- const calendarYear = ref(todayDate.getFullYear())
- const calendarMonth = ref(todayDate.getMonth() + 1)
- const pendingDay = ref(null) // 用户在网格里点击但未确认的日期
- const calendarLunarMap = ref({})
- let calendarLunarRequestId = 0
- const minCalendarYear = todayDate.getFullYear() - 20
- const maxCalendarYear = todayDate.getFullYear() + 2
- const calendarYearOptions = Array.from(
- { length: maxCalendarYear - minCalendarYear + 1 },
- (_, index) => maxCalendarYear - index,
- )
- const calendarMonthOptions = Array.from({ length: 12 }, (_, index) => index + 1)
- const calendarRangeText = `${minCalendarYear}–${maxCalendarYear} 年可选`
-
- const calendarDays = computed(() => {
- const year = calendarYear.value
- const month = calendarMonth.value
- const firstDayOfWeek = new Date(year, month - 1, 1).getDay() // 0=Sun
- const daysInMonth = new Date(year, month, 0).getDate()
- const daysInPrevMonth = new Date(year, month - 1, 0).getDate()
- const todayStr = formatYMD(todayDate)
- const currentPending = pendingDay.value
-
- const days = []
- // 上月填充
- for (let i = firstDayOfWeek - 1; i >= 0; i--) {
- const d = daysInPrevMonth - i
- const dateStr = formatYMD(year, month - 1, d)
- days.push({ key: `prev-${dateStr}`, day: d, otherMonth: true, date: dateStr, disabled: true, isToday: false, isSelected: false })
- }
- // 当月
- for (let d = 1; d <= daysInMonth; d++) {
- const dateStr = formatYMD(year, month, d)
- const isSelected = !!currentPending && currentPending === dateStr
- days.push({
- key: `cur-${dateStr}`, day: d, otherMonth: false, date: dateStr,
- disabled: false, isToday: dateStr === todayStr, isSelected,
- lunar: calendarLunarMap.value[dateStr] || null,
- ariaLabel: buildCalendarAriaLabel(dateStr, calendarLunarMap.value[dateStr], false),
- })
- }
- // 下月填充至 42 格
- let nextDay = 1
- while (days.length < 42) {
- const dateStr = formatYMD(year, month + 1, nextDay)
- days.push({ key: `next-${dateStr}`, day: nextDay, otherMonth: true, date: dateStr, disabled: true, isToday: false, isSelected: false })
- nextDay++
- }
- return days
- })
-
- const isPrevMonthDisabled = computed(() => calendarYear.value === minCalendarYear && calendarMonth.value === 1)
- const isNextMonthDisabled = computed(() => calendarYear.value === maxCalendarYear && calendarMonth.value === 12)
-
- function formatYMD(year, month, day) {
- const date = year instanceof Date ? year : new Date(year, month - 1, day)
- return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
- }
-
- function buildCalendarAriaLabel(dateStr, lunar, disabled) {
- const [year, month, day] = dateStr.split('-')
- const lunarText = lunar?.full ? `,${lunar.full}` : ''
- const disabledText = disabled ? ',不可选择' : ''
- return `${year}年${Number(month)}月${Number(day)}日${lunarText}${disabledText}`
- }
-
- async function refreshCalendarLunar(year, month) {
- const requestId = ++calendarLunarRequestId
- try {
- const { Solar } = await import('lunar-javascript')
- const lunarMap = {}
- const dayCount = new Date(year, month, 0).getDate()
- for (let day = 1; day <= dayCount; day++) {
- const dateStr = formatYMD(year, month, day)
- const solar = Solar.fromYmd(year, month, day)
- const lunar = solar.getLunar()
- const solarFestival = solar.getFestivals()[0]
- const lunarFestival = lunar.getFestivals()[0]
- const jieQi = lunar.getJieQi()
- const specialLabel = solarFestival || lunarFestival || jieQi
- const lunarDay = lunar.getDayInChinese()
- lunarMap[dateStr] = {
- label: specialLabel || (lunarDay === '初一' ? `${lunar.getMonthInChinese()}月` : lunarDay),
- full: `农历${lunar.getMonthInChinese()}月${lunarDay}`,
- special: Boolean(specialLabel),
- }
- }
- if (requestId === calendarLunarRequestId) calendarLunarMap.value = lunarMap
- } catch {
- if (requestId === calendarLunarRequestId) calendarLunarMap.value = {}
- }
- }
-
- function prevMonth() {
- if (isPrevMonthDisabled.value) return
- if (calendarMonth.value === 1) { calendarMonth.value = 12; calendarYear.value-- }
- else calendarMonth.value--
- }
- function nextMonth() {
- if (isNextMonthDisabled.value) return
- if (calendarMonth.value === 12) { calendarMonth.value = 1; calendarYear.value++ }
- else calendarMonth.value++
- }
- function selectDay(day) {
- if (day.disabled) return
- pendingDay.value = day.date
- }
- function toggleBirthInfo() {
- birthInfoIgnored.value = !birthInfoIgnored.value
- syncBirthFields()
- }
- function toggleBirthTimeUnknown() {
- if (birthInfoIgnored.value) return
- birthTimeUnknown.value = !birthTimeUnknown.value
- if (birthTimeUnknown.value) birthClock.value = ''
- syncBirthFields()
- }
- function openDatePicker() {
- if (birthInfoIgnored.value) return
- if (birthDate.value) {
- const p = birthDate.value.split('-')
- const selectedYear = Number(p[0])
- if (selectedYear >= minCalendarYear && selectedYear <= maxCalendarYear) {
- calendarYear.value = selectedYear
- calendarMonth.value = Number(p[1])
- pendingDay.value = birthDate.value
- } else {
- calendarYear.value = todayDate.getFullYear()
- calendarMonth.value = todayDate.getMonth() + 1
- pendingDay.value = null
- }
- } else {
- calendarYear.value = todayDate.getFullYear()
- calendarMonth.value = todayDate.getMonth() + 1
- pendingDay.value = null
- }
- dateOpen.value = true
- refreshCalendarLunar(calendarYear.value, calendarMonth.value)
- }
- function confirmDate() {
- if (pendingDay.value) {
- birthDate.value = pendingDay.value
- syncBirthFields()
- }
- dateOpen.value = false
- }
- function openTimePicker() {
- if (birthInfoIgnored.value) return
- const clock = birthClock.value || '08:00'
- const [h, m] = clock.split(':')
- tempHour.value = Number(h)
- tempMinute.value = Number(m)
- timeOpen.value = true
- }
- function confirmTime() {
- birthClock.value = `${padTime(tempHour.value)}:${padTime(tempMinute.value)}`
- birthTimeUnknown.value = false
- syncBirthFields()
- timeOpen.value = false
- }
- function timeUnknownPick() {
- birthTimeUnknown.value = true
- birthClock.value = ''
- syncBirthFields()
- timeOpen.value = false
- }
- function syncBirthFields() {
- if (birthInfoIgnored.value) {
- form.birth_date = ''; form.birth_clock = ''; form.birth_time_unknown = 1
- return
- }
- form.birth_date = birthDate.value || ''
- form.birth_clock = birthTimeUnknown.value ? '' : (birthClock.value || '')
- form.birth_time_unknown = birthTimeUnknown.value ? 1 : 0
- }
-
- const birthClockText = computed(() => {
- if (birthTimeUnknown.value) return '不知道具体时间'
- if (!birthClock.value) return '请选择时分'
- const hour = Number(birthClock.value.split(':')[0])
- return `${birthClock.value} · ${getShichenInfo(hour).name}`
- })
- const timePreviewText = computed(() => `${padTime(tempHour.value)}:${padTime(tempMinute.value)}`)
- const shichenInfo = computed(() => getShichenInfo(tempHour.value))
- const previewName = '白敬亭'
-
- function padTime(value) {
- return String(value).padStart(2, '0')
- }
-
- function getShichenInfo(hour) {
- const shichen = [
- { name: '子时', range: '23:00–00:59' },
- { name: '丑时', range: '01:00–02:59' },
- { name: '寅时', range: '03:00–04:59' },
- { name: '卯时', range: '05:00–06:59' },
- { name: '辰时', range: '07:00–08:59' },
- { name: '巳时', range: '09:00–10:59' },
- { name: '午时', range: '11:00–12:59' },
- { name: '未时', range: '13:00–14:59' },
- { name: '申时', range: '15:00–16:59' },
- { name: '酉时', range: '17:00–18:59' },
- { name: '戌时', range: '19:00–20:59' },
- { name: '亥时', range: '21:00–22:59' },
- ]
- return hour === 23 ? shichen[0] : shichen[Math.floor((hour + 1) / 2)]
- }
-
- watch([calendarYear, calendarMonth], ([year, month]) => {
- if (dateOpen.value) refreshCalendarLunar(year, month)
- })
-
- // 农历
- const birthLunarText = ref('')
- watch(birthDate, async (val) => {
- if (!val) { birthLunarText.value = ''; return }
- try {
- const { Solar } = await import('lunar-javascript')
- const p = val.split('-')
- const lunar = Solar.fromYmd(+p[0], +p[1], +p[2]).getLunar()
- birthLunarText.value = `农历${lunar.getYearInGanZhi()}年 ${lunar.getMonthInChinese()}${lunar.getDayInChinese()}`
- } catch { birthLunarText.value = '' }
- }, { immediate: true })
-
- // ==================== Billing & Submit ====================
- const billing = reactive({ loaded: false, points: 0 })
- const balance = reactive({ points: 0 })
- async function loadBilling() {
- try {
- const res = await api.post('/billing/config', { app_key: 'ai-name' })
- if (res.code === 0 && res.data) {
- billing.points = res.data.quote?.points || res.data.points || 0
- if (res.data.balance) balance.points = res.data.balance.points || res.data.balance.point_balance || 0
- }
- } catch {} finally { billing.loaded = true }
- }
- async function loadBalance() {
- try {
- const res = await api.post('/user/getbalance', {})
- if (res.code === 0 && res.data) balance.points = res.data.points || res.data.point_balance || 0
- } catch {}
- // 同步 header 显示的点数
- user.refreshBalance()
- }
- onMounted(() => { loadBilling(); if (user.isLogin.value) loadBalance() })
- watch(() => user.isLogin.value, (loggedIn) => {
- if (loggedIn) loadBalance()
- })
-
- const submitting = ref(false)
- const questionTitle = ref('')
- const canSubmit = computed(() => !!form.surname && !submitting.value)
- const balanceText = computed(() => {
- const cost = Number(billing.points || 0)
- if (!user.isLogin.value) return cost ? `AI取名 · ${cost}点/次` : 'AI取名'
- return cost ? `余额${balance.points}点 · 预计消耗${cost}点` : `余额${balance.points}点`
- })
- const submitText = computed(() => {
- const cost = Number(billing.points || 0)
- if (!user.isLogin.value) return cost ? `登录后取名 · ${cost}点` : '登录后取名'
- return cost ? `立即取名 · ${cost}点` : '立即取名'
- })
- const isBalanceInsufficient = computed(() => {
- const cost = Number(billing.points || 0)
- return user.isLogin.value && cost > 0 && balance.points >= 0 && balance.points < cost
- })
-
- const resultState = ref({ visible: false, loading: false, error: false, empty: false, success: false, errorMsg: '' })
- const result = ref({ summary: '', names: [] })
- const streamContent = ref('')
- const streamPreviewRef = ref(null)
-
- async function handleSubmit() {
- if (!user.isLogin.value) {
- showToast('请先登录')
- user.showLogin.value = true
- return
- }
- if (!form.surname) { showToast('请输入姓氏'); return }
- if (submitting.value) return
- syncBirthFields()
- submitting.value = true
- resultState.value = { visible: true, loading: true, error: false, empty: false, success: false, errorMsg: '' }
- result.value = { summary: '', names: [] }
- streamContent.value = ''
- questionTitle.value = `${form.surname}${form.sex === 2 ? '女孩' : form.sex === 1 ? '男孩' : ''}好名字`
-
- const params = {
- surname: form.surname, sex: form.sex, name_source: form.name_source,
- words_num: form.words_num, is_redup: form.is_redup, special_word: form.special_word,
- content: form.content, model: 4,
- birth_date: form.birth_date, birth_clock: form.birth_clock, birth_time_unknown: form.birth_time_unknown,
- }
-
- try {
- const data = await api.postStream('/ainame/send', params, {
- onMessage(packet) {
- if (!packet?.content) return
- streamContent.value += packet.content
- nextTick(() => {
- if (streamPreviewRef.value) streamPreviewRef.value.scrollTop = streamPreviewRef.value.scrollHeight
- })
- },
- })
- const parsed = data.result && Array.isArray(data.result.names)
- ? data.result
- : parseResult(data.content || streamContent.value)
- if (parsed.names && parsed.names.length > 0) {
- result.value = parsed
- resultState.value = { visible: true, loading: false, error: false, empty: false, success: true, errorMsg: '' }
- loadBalance() // 消耗点数后刷新余额(含 header)
- } else {
- result.value = parsed
- resultState.value = { visible: true, loading: false, error: false, empty: true, success: false, errorMsg: '' }
- }
- } catch (e) {
- const errorMsg = e.code === 1011 ? '点数不足,请充值后重试' : (e.message || '网络异常,请重试')
- resultState.value = { ...resultState.value, loading: false, error: true, errorMsg }
- } finally { submitting.value = false }
- }
-
- function parseResult(content) {
- if (!content) return { summary: '', names: [] }
- let text = String(content).trim().replace(/^```json\s*/i, '').replace(/^```\s*/i, '').replace(/```$/i, '').trim()
- const line = parseJsonLines(text); if (line.names.length) return line
- const marked = parseMarkedText(text); if (marked.names.length) return marked
- const s = text.indexOf('{'), e = text.lastIndexOf('}')
- if (s >= 0 && e > s) text = text.substring(s, e + 1)
- try { const d = JSON.parse(text); return { summary: d.summary || '', names: Array.isArray(d.names) ? d.names : [] } }
- catch { return { summary: text, names: [] } }
- }
- function parseJsonLines(text) {
- const d = { summary: '', names: [] }
- String(text || '').split(/\r?\n/).forEach(l => { l = l.trim(); if (!l) return; try { const i = JSON.parse(l); if (i.type === 'summary') d.summary = i.content || ''; if (i.type === 'name' && i.data) d.names.push(i.data) } catch {} })
- return d
- }
- function parseMarkedText(text) {
- const d = { summary: '', names: [] }; const v = String(text || '')
- const sm = v.match(/整体建议[::]([\s\S]*?)(?=\n\s*【|$)/); if (sm) d.summary = sm[1].trim()
- const reg = /【([^】]+)】([\s\S]*?)(?=\n\s*【|$)/g; let m
- while ((m = reg.exec(v))) {
- const body = m[2] || '', name = resolveNameBlockTitle(m[1], body)
- d.names.push({ name, pinyin: mf(body, '拼音'), score: parseInt(mf(body, '评分')) || 90, source: mf(body, '出处'), wuxing: mf(body, '五行'), meaning: mf(body, '寓意'), tone: mf(body, '音律'), reason: mf(body, '推荐') })
- }
- d.names = d.names.filter(i => i.name).slice(0, 5); return d
- }
- function resolveNameBlockTitle(title, body) {
- const n = String(title || '').trim(); if (!['姓名', '名字', '候选名'].includes(n)) return n
- const fr = /^(拼音|评分|出处|五行|寓意|音律|推荐)[::]/; const line = String(body || '').split(/\r?\n/).map(i => i.trim()).find(i => i && !fr.test(i))
- return line ? line.replace(/^姓名[::]/, '').trim() : n
- }
- function mf(body, label) { const r = new RegExp(`${label}[::]([^\\n\\r]*)`); const m = String(body || '').match(r); return m ? m[1].trim() : '' }
-
- function copyResult() {
- const names = result.value.names || []
- if (!names.length) { showToast('暂无可复制内容'); return }
- const lines = []
- if (result.value.summary) lines.push(result.value.summary)
- names.forEach(item => {
- lines.push(`${item.name}${item.score ? `(${item.score}分)` : ''}`)
- if (item.pinyin) lines.push(`拼音:${item.pinyin}`)
- if (item.source) lines.push(`出处:${item.source}`)
- if (item.wuxing) lines.push(`五行:${item.wuxing}`)
- if (item.meaning) lines.push(`寓意:${item.meaning}`)
- if (item.tone) lines.push(`音律:${item.tone}`)
- if (item.reason) lines.push(`推荐:${item.reason}`)
- lines.push('')
- })
- navigator.clipboard.writeText(lines.join('\n')).then(() => showToast('复制成功', 'primary')).catch(() => showToast('复制失败'))
- }
- function goRecharge() { router.push('/recharge') }
-
- // ==================== 历史记录 / 详情 ====================
- const historyOpen = ref(false)
- const detailOpen = ref(false)
- const currentWork = ref({})
-
- function goHistory() { historyOpen.value = true }
-
- function openHistoryItem(item) {
- currentWork.value = item
- detailOpen.value = true
- }
- </script>
-
- <style scoped>
- .animate-pulse-dot { animation: dotPulse 1.1s ease-in-out infinite; }
- .animate-pulse-dot2 { animation: dotPulse 1.1s ease-in-out 0.15s infinite; }
- .animate-pulse-dot3 { animation: dotPulse 1.1s ease-in-out 0.3s infinite; }
- @keyframes dotPulse {
- 0%, 100% { opacity: 0.35; transform: scale(0.82); }
- 50% { opacity: 1; transform: scale(1); }
- }
- .modal-enter-active, .modal-leave-active { transition: opacity 0.2s ease; }
- .modal-enter-from, .modal-leave-to { opacity: 0; }
- .modal-enter-active > div:last-child, .modal-leave-active > div:last-child { transition: transform 0.2s ease; }
- .modal-enter-from > div:last-child, .modal-leave-to > div:last-child { transform: translateY(100%); }
- @media (min-width: 640px) {
- .modal-enter-from > div:last-child, .modal-leave-to > div:last-child { transform: scale(0.95); }
- }
- </style>
|