Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 
 
 

1014 rader
56 KiB

  1. <template>
  2. <div class="min-h-screen bg-[var(--c-f3f7fb)]">
  3. <!-- ========== Hero (统一使用 AppHero,保证各页高度/底色一致) ========== -->
  4. <AppHero
  5. kicker="AI NAME STUDIO"
  6. title="给宝宝取一个有出处的好名字"
  7. desc="结合姓氏、性别、诗词典故和音律寓意,生成可直接挑选的名字方案。"
  8. >
  9. <template #actions>
  10. <button
  11. type="button"
  12. 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)]"
  13. @click="historyOpen = true"
  14. >
  15. <UIcon name="i-lucide-history" class="size-4" />
  16. 历史记录
  17. </button>
  18. </template>
  19. <template #aside>
  20. <div class="hidden w-[260px] lg:block">
  21. <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">
  22. <div class="text-3xl font-extrabold text-[var(--c-0b8cff)]">{{ previewName }}</div>
  23. <div class="mt-1.5 text-xs text-[var(--c-8a97a8)]">李白《独坐敬亭山》</div>
  24. </div>
  25. <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">
  26. <span>音律</span><span>寓意</span><span>出处</span>
  27. </div>
  28. </div>
  29. </template>
  30. </AppHero>
  31. <!-- ========== Main Grid ========== -->
  32. <div class="max-w-7xl mx-auto px-4 sm:px-6 py-6 lg:py-8">
  33. <div class="lg:grid lg:grid-cols-12 lg:gap-8">
  34. <!-- ===== LEFT: Form (col-span-7) ===== -->
  35. <div class="lg:col-span-7 space-y-5 pb-24 lg:pb-0">
  36. <!-- 1. 基础信息 Card -->
  37. <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_8px_28px_var(--sh-34-68-112-50)]">
  38. <div class="flex items-baseline mb-4">
  39. <h3 class="text-base lg:text-lg font-extrabold text-[var(--c-172033)]">基础信息</h3>
  40. <span class="ml-auto text-xs text-[var(--c-8a95a6)]">必填</span>
  41. </div>
  42. <!-- Surname + Gender (一行,对齐 V2 surname-box) -->
  43. <div class="flex items-stretch gap-3 rounded-2xl bg-[var(--c-f6f9fe)] p-3.5">
  44. <div class="flex-1 min-w-0">
  45. <label class="block text-sm font-bold text-[var(--c-172033)]">姓氏</label>
  46. <input v-model="form.surname" type="text" maxlength="5" placeholder="输入姓氏"
  47. 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" />
  48. </div>
  49. <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">
  50. <button v-for="opt in genderOptions" :key="opt.value" type="button"
  51. class="flex-1 h-10 rounded-[24px] text-sm font-bold transition-all"
  52. :class="form.sex === opt.value ? 'bg-[var(--c-0b8cff)] text-white' : 'text-[var(--c-7f8896)]'"
  53. @click="form.sex = opt.value">{{ opt.label }}</button>
  54. </div>
  55. </div>
  56. <!-- 出生信息开关 -->
  57. <div class="mt-4 rounded-2xl border-2 transition-colors"
  58. :class="birthInfoIgnored ? 'bg-[var(--c-f7f9fd)] border-[var(--c-edf2f7)]' : 'bg-[var(--c-eff8ff)] border-[var(--c-0b8cff)]/20'">
  59. <div class="flex items-center p-4">
  60. <div class="flex-1 min-w-0">
  61. <div class="text-sm font-extrabold text-[var(--c-172033)]">参考出生信息</div>
  62. <div class="mt-1 text-xs text-[var(--c-7f8896)]">{{ birthInfoIgnored ? '已关闭,生成时不参考农历和五行' : '开启后会结合农历和五行倾向' }}</div>
  63. </div>
  64. <button
  65. type="button"
  66. 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"
  67. :class="!birthInfoIgnored ? 'bg-[var(--c-0b8cff)]' : 'bg-[var(--c-d7deea)]'"
  68. :aria-pressed="!birthInfoIgnored"
  69. aria-label="切换参考出生信息"
  70. @click="toggleBirthInfo"
  71. >
  72. <div class="w-5 h-5 rounded-full bg-[var(--c-ffffff)] shadow transition-transform"
  73. :class="!birthInfoIgnored && 'translate-x-7'" />
  74. </button>
  75. </div>
  76. <div v-if="!birthInfoIgnored" class="px-4 pb-4 space-y-3">
  77. <div class="grid grid-cols-2 gap-3">
  78. <div class="rounded-2xl bg-[var(--c-f6f9fe)] p-4 flex items-start justify-between cursor-pointer min-h-[88px]"
  79. @click="openDatePicker">
  80. <div class="min-w-0">
  81. <div class="text-sm font-bold text-[var(--c-172033)]">出生日期</div>
  82. <div class="mt-2 text-sm text-[var(--c-7f8896)]">{{ birthDate || '请选择日期' }}</div>
  83. </div>
  84. <span class="ml-2 text-2xl text-[var(--c-a0aabb)] leading-none flex-shrink-0">›</span>
  85. </div>
  86. <div class="rounded-2xl bg-[var(--c-f6f9fe)] p-4 flex items-start justify-between cursor-pointer min-h-[88px]"
  87. :class="{ 'opacity-80': birthTimeUnknown }" @click="openTimePicker">
  88. <div class="min-w-0">
  89. <div class="text-sm font-bold text-[var(--c-172033)]">出生时分</div>
  90. <div class="mt-2 text-sm text-[var(--c-7f8896)]">{{ birthClockText }}</div>
  91. </div>
  92. <span class="ml-2 text-2xl text-[var(--c-a0aabb)] leading-none flex-shrink-0">›</span>
  93. </div>
  94. </div>
  95. <!-- 农历显示 -->
  96. <div v-if="birthLunarText" class="rounded-2xl bg-[var(--c-eff8ff)] p-4 flex items-center">
  97. <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>
  98. <span class="text-sm font-bold text-[var(--c-3a5878)] min-w-0">{{ birthLunarText }}</span>
  99. </div>
  100. <!-- 不知道具体时间 -->
  101. <div class="inline-flex items-center rounded-full px-4 py-2 cursor-pointer transition-colors border-2"
  102. :class="birthTimeUnknown ? 'bg-[var(--c-eff8ff)] border-[var(--c-0b8cff)] text-[var(--c-0b8cff)]' : 'bg-[var(--c-f6f9fe)] border-transparent text-[var(--c-7f8896)]'"
  103. @click="toggleBirthTimeUnknown">
  104. <span class="w-1.5 h-1.5 mr-2 rounded-full bg-current" />
  105. <span class="text-xs font-bold">不知道具体时间</span>
  106. </div>
  107. </div>
  108. </div>
  109. </div>
  110. <!-- 2. 名字出处 Card -->
  111. <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_8px_28px_var(--sh-34-68-112-50)]">
  112. <div class="flex items-baseline">
  113. <h3 class="text-base lg:text-lg font-extrabold text-[var(--c-172033)]">名字出处</h3>
  114. <span class="ml-auto text-xs text-[var(--c-8a95a6)]">选择偏好的文化来源</span>
  115. </div>
  116. <div class="mt-4 grid grid-cols-2 lg:grid-cols-3 gap-2.5">
  117. <button v-for="src in visibleSourceOptions" :key="src.id" type="button"
  118. class="min-h-[72px] p-3 rounded-2xl border-2 transition-all text-left"
  119. :class="form.name_source === src.id
  120. ? 'bg-[var(--c-eff8ff)] border-[var(--c-0b8cff)] shadow-[0_8px_20px_var(--sh-11-140-255-100)]'
  121. : 'bg-[var(--c-f7f9fd)] border-transparent hover:border-[var(--c-e7edf5)]'"
  122. @click="form.name_source = src.id">
  123. <div class="text-sm font-extrabold text-[var(--c-172033)] text-center">{{ src.label }}</div>
  124. <div class="mt-1 text-[11px] text-[var(--c-8a95a6)] text-center">{{ src.desc }}</div>
  125. </button>
  126. <button type="button"
  127. 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"
  128. @click="sourceExpanded = !sourceExpanded">
  129. <div class="text-sm font-extrabold text-[var(--c-172033)] text-center">{{ sourceExpanded ? '收起' : '更多' }}</div>
  130. <div class="mt-1 text-[11px] text-[var(--c-8a95a6)] text-center">{{ sourceExpanded ? '精简显示' : '展开全部' }}</div>
  131. </button>
  132. </div>
  133. </div>
  134. <!-- 3. 名字偏好 Card -->
  135. <div class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-[0_8px_28px_var(--sh-34-68-112-50)]">
  136. <div class="flex items-baseline">
  137. <h3 class="text-base lg:text-lg font-extrabold text-[var(--c-172033)]">名字偏好</h3>
  138. <span class="ml-auto text-xs text-[var(--c-8a95a6)]">用于控制生成结果的方向</span>
  139. </div>
  140. <!-- 字数 -->
  141. <div class="mt-5">
  142. <div class="text-sm font-bold text-[var(--c-172033)]">名字字数 <span class="text-xs font-normal text-[var(--c-8a95a6)]">包含姓氏</span></div>
  143. <div class="mt-3 flex flex-wrap gap-2.5">
  144. <button v-for="wc in wordOptions" :key="wc.id" type="button"
  145. class="h-10 px-5 rounded-full text-sm font-bold transition-all border-2"
  146. :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)]'"
  147. @click="form.words_num = wc.id">{{ wc.label }}</button>
  148. </div>
  149. </div>
  150. <!-- 是否叠词 -->
  151. <div class="mt-5">
  152. <div class="text-sm font-bold text-[var(--c-172033)]">是否叠词</div>
  153. <div class="mt-3 flex flex-wrap gap-2.5">
  154. <button v-for="r in redupOptions" :key="r.id" type="button"
  155. class="h-10 px-5 rounded-full text-sm font-bold transition-all border-2"
  156. :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)]'"
  157. @click="form.is_redup = r.id">{{ r.label }}</button>
  158. </div>
  159. </div>
  160. <!-- 指定包含字 -->
  161. <div class="mt-5 flex items-center rounded-2xl bg-[var(--c-f7f9fd)] p-4">
  162. <div class="min-w-0">
  163. <div class="text-sm font-bold text-[var(--c-172033)]">指定包含字</div>
  164. <div class="mt-1 text-xs text-[var(--c-8a95a6)]">可选,限制 1 个字</div>
  165. </div>
  166. <input v-model="form.special_word" type="text" maxlength="1" placeholder="如:辰"
  167. 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" />
  168. </div>
  169. <!-- 补充描述 -->
  170. <div class="mt-4 rounded-2xl bg-[var(--c-f7f9fd)] p-4">
  171. <div class="flex items-start justify-between gap-4">
  172. <div>
  173. <div class="text-sm font-bold text-[var(--c-172033)]">补充描述</div>
  174. <div class="mt-1 text-xs text-[var(--c-8a95a6)]">额外要求、寓意和期望</div>
  175. </div>
  176. <div class="text-xs text-[var(--c-9aa5b5)] flex-shrink-0">{{ (form.content || '').length }}/200</div>
  177. </div>
  178. <textarea v-model="form.content" rows="3" maxlength="200" placeholder="您可以补充您的额外要求,也可以添加您的寓意和期望。200字以内..."
  179. 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>
  180. </div>
  181. </div>
  182. <!-- 4. Promise Card -->
  183. <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">
  184. <div class="flex items-center">
  185. <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>
  186. <span class="text-sm text-[var(--c-596579)] leading-relaxed">可关闭出生信息参考,关闭后不结合农历和五行</span>
  187. </div>
  188. <div class="flex items-center">
  189. <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>
  190. <span class="text-sm text-[var(--c-596579)] leading-relaxed">开启后可选择日期和时分,不确定时辰可选"不知道具体时间"</span>
  191. </div>
  192. </div>
  193. <!-- Desktop Submit -->
  194. <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)]">
  195. <div class="flex-1 min-w-0">
  196. <div class="text-sm font-bold text-[var(--c-172033)] whitespace-nowrap">{{ balanceText }}</div>
  197. <div v-if="isBalanceInsufficient" class="mt-1 text-xs text-[var(--c-f04438)]">点数不足,请前往充值</div>
  198. </div>
  199. <button type="button"
  200. 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"
  201. @click="historyOpen = true">
  202. <UIcon name="i-lucide-history" class="size-4" />
  203. 历史记录
  204. </button>
  205. <button type="button"
  206. 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]"
  207. :disabled="!canSubmit || submitting" @click="handleSubmit">
  208. <UIcon v-if="!submitting" name="i-lucide-zap" class="size-4 mr-1.5" />
  209. <span>{{ submitting ? '生成中...' : submitText }}</span>
  210. </button>
  211. </div>
  212. </div>
  213. <!-- ===== RIGHT: Result (col-span-5, sticky) ===== -->
  214. <div class="lg:col-span-5">
  215. <!-- 吸顶偏移 = header 高度(64px) + 间距,避免被导航遮挡 -->
  216. <div class="space-y-4 lg:sticky lg:top-20 lg:max-h-[calc(100vh-6rem)] lg:overflow-y-auto">
  217. <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">
  218. <UIcon name="i-lucide-sparkles" class="size-12 text-[var(--c-a0aabb)] mb-4" />
  219. <div class="text-base font-medium text-[var(--c-172033)] mb-1">等待生成</div>
  220. <div class="text-sm text-[var(--c-a0aabb)]">填写左侧信息并点击"开始取名"</div>
  221. <div class="text-sm text-[var(--c-a0aabb)]">AI将为您推荐最佳名字方案</div>
  222. </div>
  223. <div v-if="resultState.loading" class="bg-[var(--c-ffffff)] rounded-2xl p-6 lg:p-8 shadow-sm">
  224. <div class="text-base font-bold text-[var(--c-172033)] mb-1">{{ questionTitle }}</div>
  225. <div class="text-sm text-[var(--c-7f8896)] mb-6">AI 正在分析音律、寓意和出处,内容将实时显示...</div>
  226. <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">
  227. {{ streamContent }}<span class="ml-1 inline-block h-4 w-1.5 animate-pulse rounded-sm bg-[var(--c-0b8cff)] align-middle" />
  228. </div>
  229. <div v-else class="rounded-2xl bg-[var(--c-f7f9fd)] py-10 flex flex-col items-center">
  230. <div class="flex gap-1.5 mb-4">
  231. <span class="w-2.5 h-2.5 rounded-full bg-[var(--c-0b8cff)] animate-pulse-dot" />
  232. <span class="w-2.5 h-2.5 rounded-full bg-[var(--c-0b8cff)] animate-pulse-dot2" />
  233. <span class="w-2.5 h-2.5 rounded-full bg-[var(--c-0b8cff)] animate-pulse-dot3" />
  234. </div>
  235. <div class="text-sm font-bold text-[var(--c-172033)]">正在整理名字方案</div>
  236. </div>
  237. </div>
  238. <div v-if="resultState.error" class="bg-[var(--c-ffffff)] rounded-2xl p-6 lg:p-8 shadow-sm text-center py-10">
  239. <UIcon name="i-lucide-circle-alert" class="size-12 text-red-400 mb-3 mx-auto" />
  240. <div class="text-base font-medium text-[var(--c-f04438)] mb-2">生成失败</div>
  241. <div class="text-sm text-[var(--c-7f8896)] mb-4">{{ resultState.errorMsg }}</div>
  242. <UButton color="primary" variant="outline" size="sm" @click="handleSubmit">重试</UButton>
  243. </div>
  244. <div v-if="resultState.empty" class="bg-[var(--c-ffffff)] rounded-2xl p-6 lg:p-8 shadow-sm text-center py-10">
  245. <UIcon name="i-lucide-search-x" class="size-12 text-[var(--c-a0aabb)] mb-3 mx-auto" />
  246. <div class="text-base font-bold text-[var(--c-172033)] mb-1">本次没有可用名字</div>
  247. <div class="text-sm text-[var(--c-7f8896)] max-w-xs mx-auto">{{ result.summary || 'AI 返回的候选名未通过姓名可用性筛选,请调整条件或重新生成。' }}</div>
  248. <UButton color="primary" size="sm" class="mt-5" @click="handleSubmit">重新生成</UButton>
  249. </div>
  250. <template v-if="resultState.success">
  251. <div v-if="result.summary" class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-sm">
  252. <div class="text-sm text-[var(--c-596579)] leading-relaxed">{{ result.summary }}</div>
  253. </div>
  254. <div v-for="(item, idx) in result.names" :key="idx" class="bg-[var(--c-ffffff)] rounded-2xl p-5 lg:p-6 shadow-sm">
  255. <div class="flex items-center justify-between mb-2">
  256. <span class="text-2xl lg:text-3xl font-bold text-[var(--c-0b8cff)]">{{ item.name }}</span>
  257. <span v-if="item.score" class="text-lg font-bold text-[var(--c-ff8a00)]">{{ item.score }}分</span>
  258. </div>
  259. <div v-if="item.pinyin" class="text-xs text-[var(--c-7f8896)] mb-3">{{ item.pinyin }}</div>
  260. <div class="space-y-2.5">
  261. <div v-for="field in nameFields" :key="field.key">
  262. <div v-if="item[field.key]" class="flex items-start gap-2.5">
  263. <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>
  264. <span class="text-sm text-[var(--c-172033)] leading-relaxed pt-0.5">{{ item[field.key] }}</span>
  265. </div>
  266. </div>
  267. </div>
  268. </div>
  269. <div class="flex gap-3">
  270. <button
  271. type="button"
  272. 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"
  273. :disabled="submitting"
  274. @click="handleSubmit"
  275. >
  276. <UIcon v-if="submitting" name="i-lucide-loader-circle" class="size-4 animate-spin" />
  277. <UIcon v-else name="i-lucide-refresh-cw" class="size-4" />
  278. {{ submitting ? '生成中...' : '重新生成' }}
  279. </button>
  280. <button
  281. type="button"
  282. 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"
  283. @click="copyResult"
  284. >
  285. <UIcon name="i-lucide-copy" class="size-4" />
  286. 复制结果
  287. </button>
  288. </div>
  289. </template>
  290. </div>
  291. </div>
  292. </div>
  293. </div>
  294. <!-- ========== Mobile Bottom Bar (fixed) ========== -->
  295. <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)]"
  296. style="padding: 14px 16px calc(14px + env(safe-area-inset-bottom))">
  297. <div class="flex items-center justify-between mb-2.5 gap-3 text-sm">
  298. <div class="min-w-0 truncate text-[var(--c-7b8797)]">{{ balanceText }}</div>
  299. <button v-if="isBalanceInsufficient" class="text-[var(--c-0b8cff)] text-xs font-extrabold flex-shrink-0" @click="goRecharge">去充值</button>
  300. </div>
  301. <div class="flex gap-3">
  302. <button class="w-[120px] h-12 rounded-full bg-[var(--c-eef6ff)] text-[var(--c-0b8cff)] text-sm font-bold" @click="goHistory">历史记录</button>
  303. <button class="flex-1 h-12 rounded-full text-white font-bold shadow-[0_12px_24px_var(--sh-11-140-255-200)] transition-opacity"
  304. :class="(canSubmit && !submitting) ? 'bg-[var(--c-0b8cff)]' : 'bg-[var(--c-0b8cff)] opacity-60'"
  305. :disabled="!canSubmit || submitting" @click="handleSubmit">
  306. {{ submitting ? '生成中...' : submitText }}
  307. </button>
  308. </div>
  309. </div>
  310. <!-- ========== 历史记录抽屉 + 详情弹窗 ========== -->
  311. <WorkHistoryDrawer v-model="historyOpen" type="ainame" @select="openHistoryItem" />
  312. <WorkDetailModal v-model="detailOpen" :work="currentWork" />
  313. <!-- ========== Date Picker Modal (solar + lunar calendar) ========== -->
  314. <Teleport to="body">
  315. <Transition name="modal">
  316. <div v-if="dateOpen" class="fixed inset-0 z-[100] flex items-end justify-center sm:items-center sm:p-4">
  317. <div class="absolute inset-0 bg-black/50" @click="dateOpen = false" />
  318. <div
  319. 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"
  320. role="dialog"
  321. aria-modal="true"
  322. aria-labelledby="birth-date-dialog-title"
  323. >
  324. <div class="flex items-center justify-between px-5 py-4 border-b border-[var(--c-f3f4f6)]">
  325. <h3 id="birth-date-dialog-title" class="text-base font-extrabold text-[var(--c-172033)]">选择出生日期</h3>
  326. <button
  327. type="button"
  328. 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)]"
  329. aria-label="关闭日期选择"
  330. @click="dateOpen = false"
  331. >
  332. <UIcon name="i-lucide-x" class="size-5" />
  333. </button>
  334. </div>
  335. <div class="overflow-y-auto px-4 py-4 sm:px-5">
  336. <div class="flex items-center justify-between mb-4">
  337. <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">
  338. <UIcon name="i-lucide-chevron-left" class="size-5" />
  339. </button>
  340. <div class="flex items-center gap-2" aria-label="选择年月">
  341. <label class="relative">
  342. <span class="sr-only">年份</span>
  343. <select
  344. v-model.number="calendarYear"
  345. 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"
  346. >
  347. <option v-for="year in calendarYearOptions" :key="year" :value="year">{{ year }}年</option>
  348. </select>
  349. <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)]" />
  350. </label>
  351. <label class="relative">
  352. <span class="sr-only">月份</span>
  353. <select
  354. v-model.number="calendarMonth"
  355. 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"
  356. >
  357. <option
  358. v-for="month in calendarMonthOptions"
  359. :key="month"
  360. :value="month"
  361. >
  362. {{ month }}月
  363. </option>
  364. </select>
  365. <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)]" />
  366. </label>
  367. </div>
  368. <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">
  369. <UIcon name="i-lucide-chevron-right" class="size-5" />
  370. </button>
  371. </div>
  372. <div class="grid grid-cols-7 gap-1 text-center text-xs text-[var(--c-7f8896)] mb-2">
  373. <div v-for="dow in ['日', '一', '二', '三', '四', '五', '六']" :key="dow" class="h-8 flex items-center justify-center">{{ dow }}</div>
  374. </div>
  375. <div class="grid grid-cols-7 gap-1">
  376. <button v-for="day in calendarDays" :key="day.key" type="button"
  377. 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)]"
  378. :class="{
  379. 'text-[var(--c-cbd5e1)]': day.otherMonth && !day.isSelected,
  380. 'text-[var(--c-172033)] hover:bg-[var(--c-f3f4f6)]': !day.otherMonth && !day.isSelected && !day.isToday && !day.disabled,
  381. 'ring-1 ring-[var(--c-0b8cff)] text-[var(--c-0b8cff)] font-bold': day.isToday && !day.isSelected,
  382. 'bg-[var(--c-0b8cff)] text-white font-bold': day.isSelected,
  383. 'opacity-30 cursor-not-allowed': day.disabled,
  384. }"
  385. :disabled="day.disabled"
  386. :aria-label="day.ariaLabel"
  387. :aria-pressed="day.isSelected"
  388. @click="selectDay(day)">
  389. <span class="leading-none">{{ day.day }}</span>
  390. <span
  391. v-if="day.lunar"
  392. class="max-w-full truncate text-[10px] font-normal leading-none"
  393. :class="day.isSelected ? 'text-white/90' : (day.lunar.special ? 'text-[var(--c-f06b3c)]' : 'text-[var(--c-8b96a8)]')"
  394. >
  395. {{ day.lunar.label }}
  396. </span>
  397. </button>
  398. </div>
  399. <div class="mt-4 flex items-center justify-center gap-2 text-xs text-[var(--c-7f8896)]">
  400. <span>日期下方为农历</span>
  401. <span class="size-1 rounded-full bg-[var(--c-cbd5e1)]" />
  402. <span>{{ calendarRangeText }}</span>
  403. </div>
  404. </div>
  405. <div class="flex gap-3 px-5 py-4 border-t border-[var(--c-f3f4f6)]">
  406. <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>
  407. <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>
  408. </div>
  409. </div>
  410. </div>
  411. </Transition>
  412. </Teleport>
  413. <!-- ========== Time Picker Modal (24-hour segmented selector) ========== -->
  414. <Teleport to="body">
  415. <Transition name="modal">
  416. <div v-if="timeOpen" class="fixed inset-0 z-[100] flex items-end justify-center sm:items-center sm:p-4">
  417. <div class="absolute inset-0 bg-black/50" @click="timeOpen = false" />
  418. <div
  419. class="relative w-full max-w-md overflow-hidden rounded-t-3xl bg-[var(--c-ffffff)] shadow-2xl sm:rounded-2xl"
  420. role="dialog"
  421. aria-modal="true"
  422. aria-labelledby="birth-time-dialog-title"
  423. >
  424. <div class="flex items-center justify-between px-5 py-4 border-b border-[var(--c-f3f4f6)]">
  425. <div>
  426. <h3 id="birth-time-dialog-title" class="text-base font-extrabold text-[var(--c-172033)]">选择出生时分</h3>
  427. <p class="mt-1 text-xs text-[var(--c-8793a5)]">使用 24 小时制</p>
  428. </div>
  429. <button
  430. type="button"
  431. 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)]"
  432. aria-label="关闭时间选择"
  433. @click="timeOpen = false"
  434. >
  435. <UIcon name="i-lucide-x" class="size-5" />
  436. </button>
  437. </div>
  438. <div class="p-5 sm:p-6">
  439. <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">
  440. <div>
  441. <div class="text-3xl font-black tabular-nums tracking-tight text-[var(--c-172033)]">{{ timePreviewText }}</div>
  442. <div class="mt-1 text-xs text-[var(--c-64748b)]">{{ shichenInfo.name }} · {{ shichenInfo.range }}</div>
  443. </div>
  444. <div class="flex size-11 items-center justify-center rounded-xl bg-[var(--c-ffffff)] text-[var(--c-0b8cff)] shadow-sm">
  445. <UIcon name="i-lucide-clock-3" class="size-5" />
  446. </div>
  447. </div>
  448. <div class="grid grid-cols-2 gap-3">
  449. <label>
  450. <span class="mb-2 block text-sm font-bold text-[var(--c-596579)]">小时</span>
  451. <span class="relative block">
  452. <select
  453. v-model.number="tempHour"
  454. 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"
  455. >
  456. <option v-for="option in hourOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
  457. </select>
  458. <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)]" />
  459. </span>
  460. </label>
  461. <label>
  462. <span class="mb-2 block text-sm font-bold text-[var(--c-596579)]">分钟</span>
  463. <span class="relative block">
  464. <select
  465. v-model.number="tempMinute"
  466. 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"
  467. >
  468. <option v-for="option in minuteOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
  469. </select>
  470. <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)]" />
  471. </span>
  472. </label>
  473. </div>
  474. <div class="mt-4">
  475. <div class="mb-2 text-xs font-medium text-[var(--c-8793a5)]">快捷选择分钟</div>
  476. <div class="grid grid-cols-4 gap-2">
  477. <button
  478. v-for="minute in quickMinuteOptions"
  479. :key="minute"
  480. type="button"
  481. 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)]"
  482. :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)]'"
  483. @click="tempMinute = minute"
  484. >
  485. :{{ padTime(minute) }}
  486. </button>
  487. </div>
  488. </div>
  489. <button
  490. type="button"
  491. 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)]"
  492. @click="timeUnknownPick"
  493. >
  494. <span>
  495. <span class="block text-sm font-bold text-[var(--c-334155)]">不知道具体时间</span>
  496. <span class="mt-0.5 block text-xs text-[var(--c-8793a5)]">仍可继续生成,系统将不使用具体时辰</span>
  497. </span>
  498. <UIcon name="i-lucide-chevron-right" class="size-5 flex-none text-[var(--c-a0aabb)]" />
  499. </button>
  500. </div>
  501. <div class="flex gap-3 px-5 py-4 border-t border-[var(--c-f3f4f6)]">
  502. <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>
  503. <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>
  504. </div>
  505. </div>
  506. </div>
  507. </Transition>
  508. </Teleport>
  509. </div>
  510. </template>
  511. <script setup>
  512. import { ref, reactive, computed, watch, onMounted, nextTick } from 'vue'
  513. const _api = useApi()
  514. const api = _api // _api 是 { get, post, request },别名保持 api.post(...) 调用
  515. await useLegacyAppSeo({
  516. appKey: 'naming',
  517. fallbackTitle: 'AI取昵称-AI取名生成器-AI取名字大全免费查询',
  518. fallbackDescription: '一个好的名字应该简洁明了,易于记忆和识别,AI取名可以通过你对海量的数据模型,给你的宝宝或者宠物取一个非常有特色的名字。',
  519. fallbackKeywords: 'AI取名、AI取昵称、取名、取名字',
  520. canonicalPath: '/writing/naming/',
  521. })
  522. const user = useUser()
  523. const router = useRouter()
  524. const toast = useToast()
  525. function showToast(message, color = 'red') {
  526. toast.add({ title: message, color })
  527. }
  528. // ==================== Options (对齐 V2) ====================
  529. const genderOptions = [
  530. { label: '男孩', value: 1 },
  531. { label: '女孩', value: 2 },
  532. ]
  533. const sourceOptions = [
  534. { id: '0', label: '不限', desc: '综合推荐' },
  535. { id: '1', label: '诗经', desc: '温润雅致' },
  536. { id: '2', label: '楚辞', desc: '浪漫开阔' },
  537. { id: '3', label: '离骚', desc: '清朗有骨' },
  538. { id: '4', label: '周易', desc: '稳重含蓄' },
  539. { id: '5', label: '史记', desc: '大气有典' },
  540. { id: '6', label: '诗词歌赋', desc: '文采舒展' },
  541. { id: '7', label: '成语典故', desc: '寓意明确' },
  542. { id: '8', label: '佛教', desc: '清净平和' },
  543. { id: '9', label: '道教', desc: '自然洒脱' },
  544. { id: '10', label: '自然景观', desc: '明亮灵动' },
  545. ]
  546. const wordOptions = [
  547. { id: '0', label: '不限' }, { id: '2', label: '2字' }, { id: '3', label: '3字' },
  548. { id: '4', label: '4字' }, { id: '5', label: '5字' },
  549. ]
  550. const redupOptions = [
  551. { id: '0', label: '不限' }, { id: '1', label: '是' }, { id: '2', label: '否' },
  552. ]
  553. const nameFields = [
  554. { key: 'source', label: '出处' }, { key: 'wuxing', label: '五行' },
  555. { key: 'meaning', label: '寓意' }, { key: 'tone', label: '音律' }, { key: 'reason', label: '推荐' },
  556. ]
  557. // ==================== Form ====================
  558. const form = reactive({
  559. surname: '', sex: 0, name_source: '0', words_num: '0',
  560. is_redup: '0', special_word: '', content: '',
  561. birth_date: '', birth_clock: '', birth_time_unknown: 1,
  562. })
  563. const sourceExpanded = ref(false)
  564. const visibleSourceOptions = computed(() => {
  565. if (sourceExpanded.value) return sourceOptions
  566. const selected = sourceOptions.find(i => i.id === form.name_source)
  567. const rows = [sourceOptions[0]]
  568. if (selected && selected.id !== '0') rows.push(selected)
  569. sourceOptions.slice(1).forEach(i => { if (rows.length < 5 && !rows.some(r => r.id === i.id)) rows.push(i) })
  570. return rows
  571. })
  572. // ==================== Birth Info ====================
  573. const birthInfoIgnored = ref(false)
  574. const birthDate = ref('')
  575. const birthClock = ref('')
  576. const birthTimeUnknown = ref(true)
  577. const dateOpen = ref(false)
  578. const timeOpen = ref(false)
  579. const tempHour = ref(8)
  580. const tempMinute = ref(0)
  581. const quickMinuteOptions = [0, 15, 30, 45]
  582. const hourOptions = Array.from({ length: 24 }, (_, value) => ({ value, label: `${String(value).padStart(2, '0')} 时` }))
  583. const minuteOptions = Array.from({ length: 60 }, (_, value) => ({ value, label: `${String(value).padStart(2, '0')} 分` }))
  584. // Custom calendar grid
  585. const todayDate = new Date()
  586. const calendarYear = ref(todayDate.getFullYear())
  587. const calendarMonth = ref(todayDate.getMonth() + 1)
  588. const pendingDay = ref(null) // 用户在网格里点击但未确认的日期
  589. const calendarLunarMap = ref({})
  590. let calendarLunarRequestId = 0
  591. const minCalendarYear = todayDate.getFullYear() - 20
  592. const maxCalendarYear = todayDate.getFullYear() + 2
  593. const calendarYearOptions = Array.from(
  594. { length: maxCalendarYear - minCalendarYear + 1 },
  595. (_, index) => maxCalendarYear - index,
  596. )
  597. const calendarMonthOptions = Array.from({ length: 12 }, (_, index) => index + 1)
  598. const calendarRangeText = `${minCalendarYear}–${maxCalendarYear} 年可选`
  599. const calendarDays = computed(() => {
  600. const year = calendarYear.value
  601. const month = calendarMonth.value
  602. const firstDayOfWeek = new Date(year, month - 1, 1).getDay() // 0=Sun
  603. const daysInMonth = new Date(year, month, 0).getDate()
  604. const daysInPrevMonth = new Date(year, month - 1, 0).getDate()
  605. const todayStr = formatYMD(todayDate)
  606. const currentPending = pendingDay.value
  607. const days = []
  608. // 上月填充
  609. for (let i = firstDayOfWeek - 1; i >= 0; i--) {
  610. const d = daysInPrevMonth - i
  611. const dateStr = formatYMD(year, month - 1, d)
  612. days.push({ key: `prev-${dateStr}`, day: d, otherMonth: true, date: dateStr, disabled: true, isToday: false, isSelected: false })
  613. }
  614. // 当月
  615. for (let d = 1; d <= daysInMonth; d++) {
  616. const dateStr = formatYMD(year, month, d)
  617. const isSelected = !!currentPending && currentPending === dateStr
  618. days.push({
  619. key: `cur-${dateStr}`, day: d, otherMonth: false, date: dateStr,
  620. disabled: false, isToday: dateStr === todayStr, isSelected,
  621. lunar: calendarLunarMap.value[dateStr] || null,
  622. ariaLabel: buildCalendarAriaLabel(dateStr, calendarLunarMap.value[dateStr], false),
  623. })
  624. }
  625. // 下月填充至 42 格
  626. let nextDay = 1
  627. while (days.length < 42) {
  628. const dateStr = formatYMD(year, month + 1, nextDay)
  629. days.push({ key: `next-${dateStr}`, day: nextDay, otherMonth: true, date: dateStr, disabled: true, isToday: false, isSelected: false })
  630. nextDay++
  631. }
  632. return days
  633. })
  634. const isPrevMonthDisabled = computed(() => calendarYear.value === minCalendarYear && calendarMonth.value === 1)
  635. const isNextMonthDisabled = computed(() => calendarYear.value === maxCalendarYear && calendarMonth.value === 12)
  636. function formatYMD(year, month, day) {
  637. const date = year instanceof Date ? year : new Date(year, month - 1, day)
  638. return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
  639. }
  640. function buildCalendarAriaLabel(dateStr, lunar, disabled) {
  641. const [year, month, day] = dateStr.split('-')
  642. const lunarText = lunar?.full ? `,${lunar.full}` : ''
  643. const disabledText = disabled ? ',不可选择' : ''
  644. return `${year}年${Number(month)}月${Number(day)}日${lunarText}${disabledText}`
  645. }
  646. async function refreshCalendarLunar(year, month) {
  647. const requestId = ++calendarLunarRequestId
  648. try {
  649. const { Solar } = await import('lunar-javascript')
  650. const lunarMap = {}
  651. const dayCount = new Date(year, month, 0).getDate()
  652. for (let day = 1; day <= dayCount; day++) {
  653. const dateStr = formatYMD(year, month, day)
  654. const solar = Solar.fromYmd(year, month, day)
  655. const lunar = solar.getLunar()
  656. const solarFestival = solar.getFestivals()[0]
  657. const lunarFestival = lunar.getFestivals()[0]
  658. const jieQi = lunar.getJieQi()
  659. const specialLabel = solarFestival || lunarFestival || jieQi
  660. const lunarDay = lunar.getDayInChinese()
  661. lunarMap[dateStr] = {
  662. label: specialLabel || (lunarDay === '初一' ? `${lunar.getMonthInChinese()}月` : lunarDay),
  663. full: `农历${lunar.getMonthInChinese()}月${lunarDay}`,
  664. special: Boolean(specialLabel),
  665. }
  666. }
  667. if (requestId === calendarLunarRequestId) calendarLunarMap.value = lunarMap
  668. } catch {
  669. if (requestId === calendarLunarRequestId) calendarLunarMap.value = {}
  670. }
  671. }
  672. function prevMonth() {
  673. if (isPrevMonthDisabled.value) return
  674. if (calendarMonth.value === 1) { calendarMonth.value = 12; calendarYear.value-- }
  675. else calendarMonth.value--
  676. }
  677. function nextMonth() {
  678. if (isNextMonthDisabled.value) return
  679. if (calendarMonth.value === 12) { calendarMonth.value = 1; calendarYear.value++ }
  680. else calendarMonth.value++
  681. }
  682. function selectDay(day) {
  683. if (day.disabled) return
  684. pendingDay.value = day.date
  685. }
  686. function toggleBirthInfo() {
  687. birthInfoIgnored.value = !birthInfoIgnored.value
  688. syncBirthFields()
  689. }
  690. function toggleBirthTimeUnknown() {
  691. if (birthInfoIgnored.value) return
  692. birthTimeUnknown.value = !birthTimeUnknown.value
  693. if (birthTimeUnknown.value) birthClock.value = ''
  694. syncBirthFields()
  695. }
  696. function openDatePicker() {
  697. if (birthInfoIgnored.value) return
  698. if (birthDate.value) {
  699. const p = birthDate.value.split('-')
  700. const selectedYear = Number(p[0])
  701. if (selectedYear >= minCalendarYear && selectedYear <= maxCalendarYear) {
  702. calendarYear.value = selectedYear
  703. calendarMonth.value = Number(p[1])
  704. pendingDay.value = birthDate.value
  705. } else {
  706. calendarYear.value = todayDate.getFullYear()
  707. calendarMonth.value = todayDate.getMonth() + 1
  708. pendingDay.value = null
  709. }
  710. } else {
  711. calendarYear.value = todayDate.getFullYear()
  712. calendarMonth.value = todayDate.getMonth() + 1
  713. pendingDay.value = null
  714. }
  715. dateOpen.value = true
  716. refreshCalendarLunar(calendarYear.value, calendarMonth.value)
  717. }
  718. function confirmDate() {
  719. if (pendingDay.value) {
  720. birthDate.value = pendingDay.value
  721. syncBirthFields()
  722. }
  723. dateOpen.value = false
  724. }
  725. function openTimePicker() {
  726. if (birthInfoIgnored.value) return
  727. const clock = birthClock.value || '08:00'
  728. const [h, m] = clock.split(':')
  729. tempHour.value = Number(h)
  730. tempMinute.value = Number(m)
  731. timeOpen.value = true
  732. }
  733. function confirmTime() {
  734. birthClock.value = `${padTime(tempHour.value)}:${padTime(tempMinute.value)}`
  735. birthTimeUnknown.value = false
  736. syncBirthFields()
  737. timeOpen.value = false
  738. }
  739. function timeUnknownPick() {
  740. birthTimeUnknown.value = true
  741. birthClock.value = ''
  742. syncBirthFields()
  743. timeOpen.value = false
  744. }
  745. function syncBirthFields() {
  746. if (birthInfoIgnored.value) {
  747. form.birth_date = ''; form.birth_clock = ''; form.birth_time_unknown = 1
  748. return
  749. }
  750. form.birth_date = birthDate.value || ''
  751. form.birth_clock = birthTimeUnknown.value ? '' : (birthClock.value || '')
  752. form.birth_time_unknown = birthTimeUnknown.value ? 1 : 0
  753. }
  754. const birthClockText = computed(() => {
  755. if (birthTimeUnknown.value) return '不知道具体时间'
  756. if (!birthClock.value) return '请选择时分'
  757. const hour = Number(birthClock.value.split(':')[0])
  758. return `${birthClock.value} · ${getShichenInfo(hour).name}`
  759. })
  760. const timePreviewText = computed(() => `${padTime(tempHour.value)}:${padTime(tempMinute.value)}`)
  761. const shichenInfo = computed(() => getShichenInfo(tempHour.value))
  762. const previewName = '白敬亭'
  763. function padTime(value) {
  764. return String(value).padStart(2, '0')
  765. }
  766. function getShichenInfo(hour) {
  767. const shichen = [
  768. { name: '子时', range: '23:00–00:59' },
  769. { name: '丑时', range: '01:00–02:59' },
  770. { name: '寅时', range: '03:00–04:59' },
  771. { name: '卯时', range: '05:00–06:59' },
  772. { name: '辰时', range: '07:00–08:59' },
  773. { name: '巳时', range: '09:00–10:59' },
  774. { name: '午时', range: '11:00–12:59' },
  775. { name: '未时', range: '13:00–14:59' },
  776. { name: '申时', range: '15:00–16:59' },
  777. { name: '酉时', range: '17:00–18:59' },
  778. { name: '戌时', range: '19:00–20:59' },
  779. { name: '亥时', range: '21:00–22:59' },
  780. ]
  781. return hour === 23 ? shichen[0] : shichen[Math.floor((hour + 1) / 2)]
  782. }
  783. watch([calendarYear, calendarMonth], ([year, month]) => {
  784. if (dateOpen.value) refreshCalendarLunar(year, month)
  785. })
  786. // 农历
  787. const birthLunarText = ref('')
  788. watch(birthDate, async (val) => {
  789. if (!val) { birthLunarText.value = ''; return }
  790. try {
  791. const { Solar } = await import('lunar-javascript')
  792. const p = val.split('-')
  793. const lunar = Solar.fromYmd(+p[0], +p[1], +p[2]).getLunar()
  794. birthLunarText.value = `农历${lunar.getYearInGanZhi()}年 ${lunar.getMonthInChinese()}${lunar.getDayInChinese()}`
  795. } catch { birthLunarText.value = '' }
  796. }, { immediate: true })
  797. // ==================== Billing & Submit ====================
  798. const billing = reactive({ loaded: false, points: 0 })
  799. const balance = reactive({ points: 0 })
  800. async function loadBilling() {
  801. try {
  802. const res = await api.post('/billing/config', { app_key: 'ai-name' })
  803. if (res.code === 0 && res.data) {
  804. billing.points = res.data.quote?.points || res.data.points || 0
  805. if (res.data.balance) balance.points = res.data.balance.points || res.data.balance.point_balance || 0
  806. }
  807. } catch {} finally { billing.loaded = true }
  808. }
  809. async function loadBalance() {
  810. try {
  811. const res = await api.post('/user/getbalance', {})
  812. if (res.code === 0 && res.data) balance.points = res.data.points || res.data.point_balance || 0
  813. } catch {}
  814. // 同步 header 显示的点数
  815. user.refreshBalance()
  816. }
  817. onMounted(() => { loadBilling(); if (user.isLogin.value) loadBalance() })
  818. watch(() => user.isLogin.value, (loggedIn) => {
  819. if (loggedIn) loadBalance()
  820. })
  821. const submitting = ref(false)
  822. const questionTitle = ref('')
  823. const canSubmit = computed(() => !!form.surname && !submitting.value)
  824. const balanceText = computed(() => {
  825. const cost = Number(billing.points || 0)
  826. if (!user.isLogin.value) return cost ? `AI取名 · ${cost}点/次` : 'AI取名'
  827. return cost ? `余额${balance.points}点 · 预计消耗${cost}点` : `余额${balance.points}点`
  828. })
  829. const submitText = computed(() => {
  830. const cost = Number(billing.points || 0)
  831. if (!user.isLogin.value) return cost ? `登录后取名 · ${cost}点` : '登录后取名'
  832. return cost ? `立即取名 · ${cost}点` : '立即取名'
  833. })
  834. const isBalanceInsufficient = computed(() => {
  835. const cost = Number(billing.points || 0)
  836. return user.isLogin.value && cost > 0 && balance.points >= 0 && balance.points < cost
  837. })
  838. const resultState = ref({ visible: false, loading: false, error: false, empty: false, success: false, errorMsg: '' })
  839. const result = ref({ summary: '', names: [] })
  840. const streamContent = ref('')
  841. const streamPreviewRef = ref(null)
  842. async function handleSubmit() {
  843. if (!user.isLogin.value) {
  844. showToast('请先登录')
  845. user.showLogin.value = true
  846. return
  847. }
  848. if (!form.surname) { showToast('请输入姓氏'); return }
  849. if (submitting.value) return
  850. syncBirthFields()
  851. submitting.value = true
  852. resultState.value = { visible: true, loading: true, error: false, empty: false, success: false, errorMsg: '' }
  853. result.value = { summary: '', names: [] }
  854. streamContent.value = ''
  855. questionTitle.value = `${form.surname}${form.sex === 2 ? '女孩' : form.sex === 1 ? '男孩' : ''}好名字`
  856. const params = {
  857. surname: form.surname, sex: form.sex, name_source: form.name_source,
  858. words_num: form.words_num, is_redup: form.is_redup, special_word: form.special_word,
  859. content: form.content, model: 4,
  860. birth_date: form.birth_date, birth_clock: form.birth_clock, birth_time_unknown: form.birth_time_unknown,
  861. }
  862. try {
  863. const data = await api.postStream('/ainame/send', params, {
  864. onMessage(packet) {
  865. if (!packet?.content) return
  866. streamContent.value += packet.content
  867. nextTick(() => {
  868. if (streamPreviewRef.value) streamPreviewRef.value.scrollTop = streamPreviewRef.value.scrollHeight
  869. })
  870. },
  871. })
  872. const parsed = data.result && Array.isArray(data.result.names)
  873. ? data.result
  874. : parseResult(data.content || streamContent.value)
  875. if (parsed.names && parsed.names.length > 0) {
  876. result.value = parsed
  877. resultState.value = { visible: true, loading: false, error: false, empty: false, success: true, errorMsg: '' }
  878. loadBalance() // 消耗点数后刷新余额(含 header)
  879. } else {
  880. result.value = parsed
  881. resultState.value = { visible: true, loading: false, error: false, empty: true, success: false, errorMsg: '' }
  882. }
  883. } catch (e) {
  884. const errorMsg = e.code === 1011 ? '点数不足,请充值后重试' : (e.message || '网络异常,请重试')
  885. resultState.value = { ...resultState.value, loading: false, error: true, errorMsg }
  886. } finally { submitting.value = false }
  887. }
  888. function parseResult(content) {
  889. if (!content) return { summary: '', names: [] }
  890. let text = String(content).trim().replace(/^```json\s*/i, '').replace(/^```\s*/i, '').replace(/```$/i, '').trim()
  891. const line = parseJsonLines(text); if (line.names.length) return line
  892. const marked = parseMarkedText(text); if (marked.names.length) return marked
  893. const s = text.indexOf('{'), e = text.lastIndexOf('}')
  894. if (s >= 0 && e > s) text = text.substring(s, e + 1)
  895. try { const d = JSON.parse(text); return { summary: d.summary || '', names: Array.isArray(d.names) ? d.names : [] } }
  896. catch { return { summary: text, names: [] } }
  897. }
  898. function parseJsonLines(text) {
  899. const d = { summary: '', names: [] }
  900. 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 {} })
  901. return d
  902. }
  903. function parseMarkedText(text) {
  904. const d = { summary: '', names: [] }; const v = String(text || '')
  905. const sm = v.match(/整体建议[::]([\s\S]*?)(?=\n\s*【|$)/); if (sm) d.summary = sm[1].trim()
  906. const reg = /【([^】]+)】([\s\S]*?)(?=\n\s*【|$)/g; let m
  907. while ((m = reg.exec(v))) {
  908. const body = m[2] || '', name = resolveNameBlockTitle(m[1], body)
  909. 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, '推荐') })
  910. }
  911. d.names = d.names.filter(i => i.name).slice(0, 5); return d
  912. }
  913. function resolveNameBlockTitle(title, body) {
  914. const n = String(title || '').trim(); if (!['姓名', '名字', '候选名'].includes(n)) return n
  915. const fr = /^(拼音|评分|出处|五行|寓意|音律|推荐)[::]/; const line = String(body || '').split(/\r?\n/).map(i => i.trim()).find(i => i && !fr.test(i))
  916. return line ? line.replace(/^姓名[::]/, '').trim() : n
  917. }
  918. function mf(body, label) { const r = new RegExp(`${label}[::]([^\\n\\r]*)`); const m = String(body || '').match(r); return m ? m[1].trim() : '' }
  919. function copyResult() {
  920. const names = result.value.names || []
  921. if (!names.length) { showToast('暂无可复制内容'); return }
  922. const lines = []
  923. if (result.value.summary) lines.push(result.value.summary)
  924. names.forEach(item => {
  925. lines.push(`${item.name}${item.score ? `(${item.score}分)` : ''}`)
  926. if (item.pinyin) lines.push(`拼音:${item.pinyin}`)
  927. if (item.source) lines.push(`出处:${item.source}`)
  928. if (item.wuxing) lines.push(`五行:${item.wuxing}`)
  929. if (item.meaning) lines.push(`寓意:${item.meaning}`)
  930. if (item.tone) lines.push(`音律:${item.tone}`)
  931. if (item.reason) lines.push(`推荐:${item.reason}`)
  932. lines.push('')
  933. })
  934. navigator.clipboard.writeText(lines.join('\n')).then(() => showToast('复制成功', 'primary')).catch(() => showToast('复制失败'))
  935. }
  936. function goRecharge() { router.push('/recharge') }
  937. // ==================== 历史记录 / 详情 ====================
  938. const historyOpen = ref(false)
  939. const detailOpen = ref(false)
  940. const currentWork = ref({})
  941. function goHistory() { historyOpen.value = true }
  942. function openHistoryItem(item) {
  943. currentWork.value = item
  944. detailOpen.value = true
  945. }
  946. </script>
  947. <style scoped>
  948. .animate-pulse-dot { animation: dotPulse 1.1s ease-in-out infinite; }
  949. .animate-pulse-dot2 { animation: dotPulse 1.1s ease-in-out 0.15s infinite; }
  950. .animate-pulse-dot3 { animation: dotPulse 1.1s ease-in-out 0.3s infinite; }
  951. @keyframes dotPulse {
  952. 0%, 100% { opacity: 0.35; transform: scale(0.82); }
  953. 50% { opacity: 1; transform: scale(1); }
  954. }
  955. .modal-enter-active, .modal-leave-active { transition: opacity 0.2s ease; }
  956. .modal-enter-from, .modal-leave-to { opacity: 0; }
  957. .modal-enter-active > div:last-child, .modal-leave-active > div:last-child { transition: transform 0.2s ease; }
  958. .modal-enter-from > div:last-child, .modal-leave-to > div:last-child { transform: translateY(100%); }
  959. @media (min-width: 640px) {
  960. .modal-enter-from > div:last-child, .modal-leave-to > div:last-child { transform: scale(0.95); }
  961. }
  962. </style>