Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

724 linhas
38 KiB

  1. <template>
  2. <div class="min-h-screen bg-[#f3f7fb]">
  3. <!-- ========== Hero (对齐 V2 渐变风格) ========== -->
  4. <div class="relative overflow-hidden text-white"
  5. style="background: linear-gradient(135deg, #138cff 0%, #34c3ff 54%, #79e0df 100%)">
  6. <div class="absolute w-[360px] h-[360px] rounded-full bg-white/20 -right-[110px] -top-[140px]" />
  7. <div class="absolute w-[240px] h-[240px] rounded-full bg-white/15 right-[86px] -bottom-[130px]" />
  8. <div class="relative max-w-7xl mx-auto px-4 sm:px-6 py-8 lg:py-10">
  9. <div class="lg:flex lg:items-end lg:justify-between gap-8">
  10. <div class="max-w-[460px]">
  11. <div class="text-[11px] lg:text-xs font-bold tracking-wider text-white/75">AI NAME STUDIO</div>
  12. <h1 class="mt-3 text-[26px] lg:text-[36px] font-extrabold leading-tight">给宝宝取一个有出处的好名字</h1>
  13. <p class="mt-3 text-sm lg:text-base text-white/90 leading-relaxed">结合姓氏、性别、诗词典故和音律寓意,生成可直接挑选的名字方案。</p>
  14. </div>
  15. <div class="hidden lg:block w-[260px] flex-shrink-0">
  16. <div class="rounded-3xl border border-white/40 bg-white/20 backdrop-blur p-5 shadow-[0_18px_34px_rgba(0,92,190,0.16)]">
  17. <div class="text-3xl font-extrabold">{{ previewName }}</div>
  18. <div class="mt-1.5 text-xs text-white/85">李白《独坐敬亭山》</div>
  19. </div>
  20. <div class="mt-3 h-12 rounded-2xl border border-white/40 bg-white/20 backdrop-blur flex items-center justify-between px-4 text-xs text-white/90">
  21. <span>音律</span><span>寓意</span><span>出处</span>
  22. </div>
  23. </div>
  24. </div>
  25. </div>
  26. </div>
  27. <!-- ========== Main Grid ========== -->
  28. <div class="max-w-7xl mx-auto px-4 sm:px-6 py-6 lg:py-8">
  29. <div class="lg:grid lg:grid-cols-12 lg:gap-8">
  30. <!-- ===== LEFT: Form (col-span-7) ===== -->
  31. <div class="lg:col-span-7 space-y-5 pb-24 lg:pb-0">
  32. <!-- 1. 基础信息 Card -->
  33. <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_8px_28px_rgba(34,68,112,0.05)]">
  34. <div class="flex items-baseline mb-4">
  35. <h3 class="text-base lg:text-lg font-extrabold text-[#172033]">基础信息</h3>
  36. <span class="ml-auto text-xs text-[#8a95a6]">必填</span>
  37. </div>
  38. <!-- Surname + Gender (一行,对齐 V2 surname-box) -->
  39. <div class="flex items-stretch gap-3 rounded-2xl bg-[#f6f9fe] p-3.5">
  40. <div class="flex-1 min-w-0">
  41. <label class="block text-sm font-bold text-[#172033]">姓氏</label>
  42. <input v-model="form.surname" type="text" maxlength="5" placeholder="输入姓氏"
  43. class="w-full mt-1 bg-transparent text-xl lg:text-2xl font-extrabold text-[#172033] placeholder:text-[#a0aabb] focus:outline-none" />
  44. </div>
  45. <div class="w-[180px] flex-shrink-0 p-1.5 rounded-[28px] bg-white shadow-[inset_0_0_0_1px_#edf2f7] flex">
  46. <button v-for="opt in genderOptions" :key="opt.value" type="button"
  47. class="flex-1 h-10 rounded-[24px] text-sm font-bold transition-all"
  48. :class="form.sex === opt.value ? 'bg-[#0b8cff] text-white' : 'text-[#7f8896]'"
  49. @click="form.sex = opt.value">{{ opt.label }}</button>
  50. </div>
  51. </div>
  52. <!-- 出生信息开关 -->
  53. <div class="mt-4 rounded-2xl border-2 transition-colors"
  54. :class="birthInfoIgnored ? 'bg-[#f7f9fd] border-[#edf2f7]' : 'bg-[#eff8ff] border-[#0b8cff]/20'">
  55. <div class="flex items-center p-4 cursor-pointer" @click="toggleBirthInfo">
  56. <div class="flex-1 min-w-0">
  57. <div class="text-sm font-extrabold text-[#172033]">参考出生信息</div>
  58. <div class="mt-1 text-xs text-[#7f8896]">{{ birthInfoIgnored ? '已关闭,生成时不参考农历和五行' : '开启后会结合农历和五行倾向' }}</div>
  59. </div>
  60. <div class="ml-3 w-[60px] h-7 p-1 rounded-full transition-colors flex-shrink-0"
  61. :class="!birthInfoIgnored ? 'bg-[#0b8cff]' : 'bg-[#d7deea]'">
  62. <div class="w-5 h-5 rounded-full bg-white shadow transition-transform"
  63. :class="!birthInfoIgnored && 'translate-x-7'" />
  64. </div>
  65. </div>
  66. <div v-if="!birthInfoIgnored" class="px-4 pb-4 space-y-3">
  67. <div class="grid grid-cols-2 gap-3">
  68. <div class="rounded-2xl bg-[#f6f9fe] p-4 flex items-start justify-between cursor-pointer min-h-[88px]"
  69. @click="openDatePicker">
  70. <div class="min-w-0">
  71. <div class="text-sm font-bold text-[#172033]">出生日期</div>
  72. <div class="mt-2 text-sm text-[#7f8896]">{{ birthDate || '请选择日期' }}</div>
  73. </div>
  74. <span class="ml-2 text-2xl text-[#a0aabb] leading-none flex-shrink-0">›</span>
  75. </div>
  76. <div class="rounded-2xl bg-[#f6f9fe] p-4 flex items-start justify-between cursor-pointer min-h-[88px]"
  77. :class="{ 'opacity-80': birthTimeUnknown }" @click="openTimePicker">
  78. <div class="min-w-0">
  79. <div class="text-sm font-bold text-[#172033]">出生时分</div>
  80. <div class="mt-2 text-sm text-[#7f8896]">{{ birthClockText }}</div>
  81. </div>
  82. <span class="ml-2 text-2xl text-[#a0aabb] leading-none flex-shrink-0">›</span>
  83. </div>
  84. </div>
  85. <!-- 农历显示 -->
  86. <div v-if="birthLunarText" class="rounded-2xl bg-[#eff8ff] p-4 flex items-center">
  87. <span class="mr-3 px-3 py-0.5 rounded-full bg-white text-[#0b8cff] text-xs font-extrabold flex-shrink-0">农历</span>
  88. <span class="text-sm font-bold text-[#3a5878] min-w-0">{{ birthLunarText }}</span>
  89. </div>
  90. <!-- 不知道具体时间 -->
  91. <div class="inline-flex items-center rounded-full px-4 py-2 cursor-pointer transition-colors border-2"
  92. :class="birthTimeUnknown ? 'bg-[#eff8ff] border-[#0b8cff] text-[#0b8cff]' : 'bg-[#f6f9fe] border-transparent text-[#7f8896]'"
  93. @click="toggleBirthTimeUnknown">
  94. <span class="w-1.5 h-1.5 mr-2 rounded-full bg-current" />
  95. <span class="text-xs font-bold">不知道具体时间</span>
  96. </div>
  97. </div>
  98. </div>
  99. </div>
  100. <!-- 2. 名字出处 Card -->
  101. <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_8px_28px_rgba(34,68,112,0.05)]">
  102. <div class="flex items-baseline">
  103. <h3 class="text-base lg:text-lg font-extrabold text-[#172033]">名字出处</h3>
  104. <span class="ml-auto text-xs text-[#8a95a6]">选择偏好的文化来源</span>
  105. </div>
  106. <div class="mt-4 grid grid-cols-2 lg:grid-cols-3 gap-2.5">
  107. <button v-for="src in visibleSourceOptions" :key="src.id" type="button"
  108. class="min-h-[72px] p-3 rounded-2xl border-2 transition-all text-left"
  109. :class="form.name_source === src.id
  110. ? 'bg-[#eff8ff] border-[#0b8cff] shadow-[0_8px_20px_rgba(11,140,255,0.1)]'
  111. : 'bg-[#f7f9fd] border-transparent hover:border-[#e7edf5]'"
  112. @click="form.name_source = src.id">
  113. <div class="text-sm font-extrabold text-[#172033] text-center">{{ src.label }}</div>
  114. <div class="mt-1 text-[11px] text-[#8a95a6] text-center">{{ src.desc }}</div>
  115. </button>
  116. <button type="button"
  117. class="min-h-[72px] p-3 rounded-2xl border-2 bg-white border-[#e7edf5] hover:border-[#0b8cff]/30 transition-all text-left"
  118. @click="sourceExpanded = !sourceExpanded">
  119. <div class="text-sm font-extrabold text-[#172033] text-center">{{ sourceExpanded ? '收起' : '更多' }}</div>
  120. <div class="mt-1 text-[11px] text-[#8a95a6] text-center">{{ sourceExpanded ? '精简显示' : '展开全部' }}</div>
  121. </button>
  122. </div>
  123. </div>
  124. <!-- 3. 名字偏好 Card -->
  125. <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_8px_28px_rgba(34,68,112,0.05)]">
  126. <div class="flex items-baseline">
  127. <h3 class="text-base lg:text-lg font-extrabold text-[#172033]">名字偏好</h3>
  128. <span class="ml-auto text-xs text-[#8a95a6]">用于控制生成结果的方向</span>
  129. </div>
  130. <!-- 字数 -->
  131. <div class="mt-5">
  132. <div class="text-sm font-bold text-[#172033]">名字字数 <span class="text-xs font-normal text-[#8a95a6]">包含姓氏</span></div>
  133. <div class="mt-3 flex flex-wrap gap-2.5">
  134. <button v-for="wc in wordOptions" :key="wc.id" type="button"
  135. class="h-10 px-5 rounded-full text-sm font-bold transition-all border-2"
  136. :class="form.words_num === wc.id ? 'bg-[#eff8ff] border-[#0b8cff] text-[#0b8cff]' : 'bg-[#f6f9fe] border-transparent text-[#596579]'"
  137. @click="form.words_num = wc.id">{{ wc.label }}</button>
  138. </div>
  139. </div>
  140. <!-- 是否叠词 -->
  141. <div class="mt-5">
  142. <div class="text-sm font-bold text-[#172033]">是否叠词</div>
  143. <div class="mt-3 flex flex-wrap gap-2.5">
  144. <button v-for="r in redupOptions" :key="r.id" type="button"
  145. class="h-10 px-5 rounded-full text-sm font-bold transition-all border-2"
  146. :class="form.is_redup === r.id ? 'bg-[#eff8ff] border-[#0b8cff] text-[#0b8cff]' : 'bg-[#f6f9fe] border-transparent text-[#596579]'"
  147. @click="form.is_redup = r.id">{{ r.label }}</button>
  148. </div>
  149. </div>
  150. <!-- 指定包含字 -->
  151. <div class="mt-5 flex items-center rounded-2xl bg-[#f7f9fd] p-4">
  152. <div class="min-w-0">
  153. <div class="text-sm font-bold text-[#172033]">指定包含字</div>
  154. <div class="mt-1 text-xs text-[#8a95a6]">可选,限制 1 个字</div>
  155. </div>
  156. <input v-model="form.special_word" type="text" maxlength="1" placeholder="如:辰"
  157. class="ml-auto w-24 h-12 rounded-2xl bg-white text-center text-lg font-extrabold text-[#172033] placeholder:text-[#a0aabb] focus:outline-none focus:ring-2 focus:ring-[#0b8cff]/30" />
  158. </div>
  159. <!-- 补充描述 -->
  160. <div class="mt-4 rounded-2xl bg-[#f7f9fd] p-4">
  161. <div class="flex items-start justify-between gap-4">
  162. <div>
  163. <div class="text-sm font-bold text-[#172033]">补充描述</div>
  164. <div class="mt-1 text-xs text-[#8a95a6]">额外要求、寓意和期望</div>
  165. </div>
  166. <div class="text-xs text-[#9aa5b5] flex-shrink-0">{{ (form.content || '').length }}/200</div>
  167. </div>
  168. <textarea v-model="form.content" rows="3" maxlength="200" placeholder="您可以补充您的额外要求,也可以添加您的寓意和期望。200字以内..."
  169. class="mt-3 w-full bg-transparent text-sm text-[#172033] placeholder:text-[#a0aabb] focus:outline-none resize-none leading-relaxed"></textarea>
  170. </div>
  171. </div>
  172. <!-- 4. Promise Card -->
  173. <div class="bg-white rounded-2xl p-5 lg:p-6 shadow-[0_8px_28px_rgba(34,68,112,0.05)] space-y-4">
  174. <div class="flex items-center">
  175. <span class="w-7 h-5 mr-3 rounded-[14px] bg-[#eef6ff] text-[#0b8cff] text-[11px] font-extrabold flex items-center justify-center flex-shrink-0">01</span>
  176. <span class="text-sm text-[#596579] leading-relaxed">可关闭出生信息参考,关闭后不结合农历和五行</span>
  177. </div>
  178. <div class="flex items-center">
  179. <span class="w-7 h-5 mr-3 rounded-[14px] bg-[#eef6ff] text-[#0b8cff] text-[11px] font-extrabold flex items-center justify-center flex-shrink-0">02</span>
  180. <span class="text-sm text-[#596579] leading-relaxed">开启后可选择日期和时分,不确定时辰可选"不知道具体时间"</span>
  181. </div>
  182. </div>
  183. <!-- Desktop Submit -->
  184. <div class="hidden lg:flex items-center gap-3 rounded-2xl bg-white p-4 shadow-[0_8px_28px_rgba(34,68,112,0.05)]">
  185. <div class="flex-1 min-w-0">
  186. <div class="text-sm font-bold text-[#172033] whitespace-nowrap">{{ balanceText }}</div>
  187. <div v-if="isBalanceInsufficient" class="mt-1 text-xs text-[#f04438]">点数不足,请前往充值</div>
  188. </div>
  189. <button type="button"
  190. class="px-10 h-14 rounded-full bg-[#0b8cff] hover:bg-[#0a7ce0] active:bg-[#096dca] text-white font-bold text-base shadow-[0_12px_24px_rgba(11,140,255,0.25)] transition-all flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center min-w-[160px]"
  191. :disabled="!canSubmit || submitting" @click="handleSubmit">
  192. <UIcon v-if="!submitting" name="i-lucide-zap" class="size-4 mr-1.5" />
  193. <span>{{ submitting ? '生成中...' : submitText }}</span>
  194. </button>
  195. </div>
  196. </div>
  197. <!-- ===== RIGHT: Result (col-span-5, sticky) ===== -->
  198. <div class="lg:col-span-5">
  199. <div class="lg:sticky lg:top-6 space-y-4">
  200. <div v-if="!resultState.visible" class="bg-white rounded-2xl p-8 lg:p-10 shadow-sm text-center min-h-[300px] flex flex-col items-center justify-center">
  201. <UIcon name="i-lucide-sparkles" class="size-12 text-[#a0aabb] mb-4" />
  202. <div class="text-base font-medium text-[#172033] mb-1">等待生成</div>
  203. <div class="text-sm text-[#a0aabb]">填写左侧信息并点击"开始取名"</div>
  204. <div class="text-sm text-[#a0aabb]">AI将为您推荐最佳名字方案</div>
  205. </div>
  206. <div v-if="resultState.loading" class="bg-white rounded-2xl p-6 lg:p-8 shadow-sm">
  207. <div class="text-base font-bold text-[#172033] mb-1">{{ questionTitle }}</div>
  208. <div class="text-sm text-[#7f8896] mb-6">AI 正在分析音律、寓意和出处...</div>
  209. <div class="rounded-2xl bg-[#f7f9fd] py-10 flex flex-col items-center">
  210. <div class="flex gap-1.5 mb-4">
  211. <span class="w-2.5 h-2.5 rounded-full bg-[#0b8cff] animate-pulse-dot" />
  212. <span class="w-2.5 h-2.5 rounded-full bg-[#0b8cff] animate-pulse-dot2" />
  213. <span class="w-2.5 h-2.5 rounded-full bg-[#0b8cff] animate-pulse-dot3" />
  214. </div>
  215. <div class="text-sm font-bold text-[#172033]">正在整理名字方案</div>
  216. </div>
  217. </div>
  218. <div v-if="resultState.error" class="bg-white rounded-2xl p-6 lg:p-8 shadow-sm text-center py-10">
  219. <UIcon name="i-lucide-circle-alert" class="size-12 text-red-400 mb-3 mx-auto" />
  220. <div class="text-base font-medium text-[#f04438] mb-2">生成失败</div>
  221. <div class="text-sm text-[#7f8896] mb-4">{{ resultState.errorMsg }}</div>
  222. <UButton color="primary" variant="outline" size="sm" @click="handleSubmit">重试</UButton>
  223. </div>
  224. <div v-if="resultState.empty" class="bg-white rounded-2xl p-6 lg:p-8 shadow-sm text-center py-10">
  225. <UIcon name="i-lucide-search-x" class="size-12 text-[#a0aabb] mb-3 mx-auto" />
  226. <div class="text-base font-bold text-[#172033] mb-1">本次没有可用名字</div>
  227. <div class="text-sm text-[#7f8896] max-w-xs mx-auto">{{ result.summary || 'AI 返回的候选名未通过姓名可用性筛选,请调整条件或重新生成。' }}</div>
  228. <UButton color="primary" size="sm" class="mt-5" @click="handleSubmit">重新生成</UButton>
  229. </div>
  230. <template v-if="resultState.success">
  231. <div v-if="result.summary" class="bg-white rounded-2xl p-5 lg:p-6 shadow-sm">
  232. <div class="text-sm text-[#596579] leading-relaxed">{{ result.summary }}</div>
  233. </div>
  234. <div v-for="(item, idx) in result.names" :key="idx" class="bg-white rounded-2xl p-5 lg:p-6 shadow-sm">
  235. <div class="flex items-center justify-between mb-2">
  236. <span class="text-2xl lg:text-3xl font-bold text-[#0b8cff]">{{ item.name }}</span>
  237. <span v-if="item.score" class="text-lg font-bold text-[#ff8a00]">{{ item.score }}分</span>
  238. </div>
  239. <div v-if="item.pinyin" class="text-xs text-[#7f8896] mb-3">{{ item.pinyin }}</div>
  240. <div class="space-y-2.5">
  241. <div v-for="field in nameFields" :key="field.key">
  242. <div v-if="item[field.key]" class="flex items-start gap-2.5">
  243. <span class="flex-shrink-0 inline-flex items-center justify-center h-[26px] px-2 rounded-full bg-[#eef6ff] text-[#0b8cff] text-[11px] font-medium">{{ field.label }}</span>
  244. <span class="text-sm text-[#172033] leading-relaxed pt-0.5">{{ item[field.key] }}</span>
  245. </div>
  246. </div>
  247. </div>
  248. </div>
  249. <div class="flex gap-3">
  250. <UButton color="primary" variant="outline" size="lg" class="flex-1 !rounded-full" :loading="submitting" @click="handleSubmit">重新生成</UButton>
  251. <UButton color="primary" size="lg" class="flex-1 !rounded-full" @click="copyResult">复制结果</UButton>
  252. </div>
  253. </template>
  254. </div>
  255. </div>
  256. </div>
  257. </div>
  258. <!-- ========== Mobile Bottom Bar (fixed) ========== -->
  259. <div class="lg:hidden fixed left-0 right-0 bottom-0 z-50 bg-white/95 backdrop-blur shadow-[0_-8px_24px_rgba(35,55,90,0.08)]"
  260. style="padding: 14px 16px calc(14px + env(safe-area-inset-bottom))">
  261. <div class="flex items-center justify-between mb-2.5 gap-3 text-sm">
  262. <div class="min-w-0 truncate text-[#7b8797]">{{ balanceText }}</div>
  263. <button v-if="isBalanceInsufficient" class="text-[#0b8cff] text-xs font-extrabold flex-shrink-0" @click="goRecharge">去充值</button>
  264. </div>
  265. <div class="flex gap-3">
  266. <button class="w-[120px] h-12 rounded-full bg-[#eef6ff] text-[#0b8cff] text-sm font-bold" @click="goHistory">历史记录</button>
  267. <button class="flex-1 h-12 rounded-full text-white font-bold shadow-[0_12px_24px_rgba(11,140,255,0.2)] transition-opacity"
  268. :class="(canSubmit && !submitting) ? 'bg-[#0b8cff]' : 'bg-[#0b8cff] opacity-60'"
  269. :disabled="!canSubmit || submitting" @click="handleSubmit">
  270. {{ submitting ? '生成中...' : submitText }}
  271. </button>
  272. </div>
  273. </div>
  274. <!-- ========== Date Picker Modal (custom calendar grid) ========== -->
  275. <Teleport to="body">
  276. <Transition name="modal">
  277. <div v-if="dateOpen" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
  278. <div class="absolute inset-0 bg-black/50" @click="dateOpen = false" />
  279. <div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
  280. <div class="flex items-center justify-between px-5 py-4 border-b border-gray-100">
  281. <h3 class="text-base font-extrabold text-[#172033]">选择出生日期</h3>
  282. <button class="text-[#a0aabb] text-2xl leading-none w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100" @click="dateOpen = false">×</button>
  283. </div>
  284. <div class="p-5">
  285. <div class="flex items-center justify-between mb-4">
  286. <button type="button" class="w-9 h-9 rounded-full hover:bg-gray-100 flex items-center justify-center text-[#172033]" @click="prevMonth">
  287. <UIcon name="i-lucide-chevron-left" class="size-5" />
  288. </button>
  289. <div class="text-base font-extrabold text-[#172033]">{{ calendarYear }}年{{ calendarMonth }}月</div>
  290. <button type="button" class="w-9 h-9 rounded-full hover:bg-gray-100 flex items-center justify-center text-[#172033] disabled:opacity-30 disabled:cursor-not-allowed" :disabled="isNextMonthDisabled" @click="nextMonth">
  291. <UIcon name="i-lucide-chevron-right" class="size-5" />
  292. </button>
  293. </div>
  294. <div class="grid grid-cols-7 gap-1 text-center text-xs text-[#7f8896] mb-2">
  295. <div v-for="dow in ['日', '一', '二', '三', '四', '五', '六']" :key="dow" class="h-8 flex items-center justify-center">{{ dow }}</div>
  296. </div>
  297. <div class="grid grid-cols-7 gap-1">
  298. <button v-for="day in calendarDays" :key="day.key" type="button"
  299. class="aspect-square flex items-center justify-center text-sm rounded-full transition-colors relative"
  300. :class="{
  301. 'text-[#cbd5e1]': day.otherMonth && !day.isSelected,
  302. 'text-[#172033] hover:bg-gray-100': !day.otherMonth && !day.isSelected && !day.isToday && !day.disabled,
  303. 'ring-1 ring-[#0b8cff] text-[#0b8cff] font-bold': day.isToday && !day.isSelected,
  304. 'bg-[#0b8cff] text-white font-bold': day.isSelected,
  305. 'opacity-30 cursor-not-allowed': day.disabled,
  306. }"
  307. :disabled="day.disabled"
  308. @click="selectDay(day)">
  309. {{ day.day }}
  310. </button>
  311. </div>
  312. <div class="mt-4 text-center text-xs text-[#7f8896]">小贴士:可选过去日期,今日及之前可选</div>
  313. </div>
  314. <div class="flex gap-3 px-5 py-4 border-t border-gray-100">
  315. <button type="button" class="flex-1 h-11 rounded-full border border-gray-200 text-[#596579] font-bold hover:bg-gray-50" @click="dateOpen = false">取消</button>
  316. <button type="button" class="flex-1 h-11 rounded-full bg-[#0b8cff] text-white font-bold hover:bg-[#0a7ce0]" @click="confirmDate">确定</button>
  317. </div>
  318. </div>
  319. </div>
  320. </Transition>
  321. </Teleport>
  322. <!-- ========== Time Picker Modal (UInputTime) ========== -->
  323. <Teleport to="body">
  324. <Transition name="modal">
  325. <div v-if="timeOpen" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
  326. <div class="absolute inset-0 bg-black/50" @click="timeOpen = false" />
  327. <div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
  328. <div class="flex items-center justify-between px-5 py-4 border-b border-gray-100">
  329. <h3 class="text-base font-extrabold text-[#172033]">选择出生时分</h3>
  330. <button class="text-[#a0aabb] text-2xl leading-none w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100" @click="timeOpen = false">×</button>
  331. </div>
  332. <div class="p-6">
  333. <UInputTime v-model="tempTimeValue" class="w-full" />
  334. <div class="mt-4 text-center text-xs text-[#7f8896]">不确定时辰?点下方"不知道具体时间"</div>
  335. </div>
  336. <div class="flex gap-3 px-5 py-4 border-t border-gray-100">
  337. <button type="button" class="flex-1 h-11 rounded-full border border-gray-200 text-[#0b8cff] font-bold hover:bg-gray-50" @click="timeUnknownPick">不知道具体时间</button>
  338. <button type="button" class="flex-1 h-11 rounded-full bg-[#0b8cff] text-white font-bold hover:bg-[#0a7ce0]" @click="confirmTime">确定</button>
  339. </div>
  340. </div>
  341. </div>
  342. </Transition>
  343. </Teleport>
  344. </div>
  345. </template>
  346. <script setup>
  347. import { ref, reactive, computed, watch, onMounted } from 'vue'
  348. import { Time } from '@internationalized/date'
  349. const _api = useApi()
  350. const api = _api // _api 是 { get, post, request },别名保持 api.post(...) 调用
  351. const user = useUser()
  352. const router = useRouter()
  353. const toast = useToast()
  354. function showToast(message, color = 'red') {
  355. toast.add({ title: message, color })
  356. }
  357. // ==================== Options (对齐 V2) ====================
  358. const genderOptions = [
  359. { label: '男孩', value: 1 },
  360. { label: '女孩', value: 2 },
  361. ]
  362. const sourceOptions = [
  363. { id: '0', label: '不限', desc: '综合推荐' },
  364. { id: '1', label: '诗经', desc: '温润雅致' },
  365. { id: '2', label: '楚辞', desc: '浪漫开阔' },
  366. { id: '3', label: '离骚', desc: '清朗有骨' },
  367. { id: '4', label: '周易', desc: '稳重含蓄' },
  368. { id: '5', label: '史记', desc: '大气有典' },
  369. { id: '6', label: '诗词歌赋', desc: '文采舒展' },
  370. { id: '7', label: '成语典故', desc: '寓意明确' },
  371. { id: '8', label: '佛教', desc: '清净平和' },
  372. { id: '9', label: '道教', desc: '自然洒脱' },
  373. { id: '10', label: '自然景观', desc: '明亮灵动' },
  374. ]
  375. const wordOptions = [
  376. { id: '0', label: '不限' }, { id: '2', label: '2字' }, { id: '3', label: '3字' },
  377. { id: '4', label: '4字' }, { id: '5', label: '5字' },
  378. ]
  379. const redupOptions = [
  380. { id: '0', label: '不限' }, { id: '1', label: '是' }, { id: '2', label: '否' },
  381. ]
  382. const nameFields = [
  383. { key: 'source', label: '出处' }, { key: 'wuxing', label: '五行' },
  384. { key: 'meaning', label: '寓意' }, { key: 'tone', label: '音律' }, { key: 'reason', label: '推荐' },
  385. ]
  386. // ==================== Form ====================
  387. const form = reactive({
  388. surname: '', sex: 0, name_source: '0', words_num: '0',
  389. is_redup: '0', special_word: '', content: '',
  390. birth_date: '', birth_clock: '', birth_time_unknown: 1,
  391. })
  392. const sourceExpanded = ref(false)
  393. const visibleSourceOptions = computed(() => {
  394. if (sourceExpanded.value) return sourceOptions
  395. const selected = sourceOptions.find(i => i.id === form.name_source)
  396. const rows = [sourceOptions[0]]
  397. if (selected && selected.id !== '0') rows.push(selected)
  398. sourceOptions.slice(1).forEach(i => { if (rows.length < 5 && !rows.some(r => r.id === i.id)) rows.push(i) })
  399. return rows
  400. })
  401. // ==================== Birth Info ====================
  402. const birthInfoIgnored = ref(false)
  403. const birthDate = ref('')
  404. const birthClock = ref('')
  405. const birthTimeUnknown = ref(true)
  406. const dateOpen = ref(false)
  407. const timeOpen = ref(false)
  408. const tempTimeValue = ref(new Time(8, 0))
  409. // Custom calendar grid
  410. const todayDate = new Date()
  411. const calendarYear = ref(todayDate.getFullYear())
  412. const calendarMonth = ref(todayDate.getMonth() + 1)
  413. const pendingDay = ref(null) // 用户在网格里点击但未确认的日期
  414. const calendarDays = computed(() => {
  415. const year = calendarYear.value
  416. const month = calendarMonth.value
  417. const firstDayOfWeek = new Date(year, month - 1, 1).getDay() // 0=Sun
  418. const daysInMonth = new Date(year, month, 0).getDate()
  419. const daysInPrevMonth = new Date(year, month - 1, 0).getDate()
  420. const todayStr = formatYMD(todayDate)
  421. const currentPending = pendingDay.value
  422. const days = []
  423. // 上月填充
  424. for (let i = firstDayOfWeek - 1; i >= 0; i--) {
  425. const d = daysInPrevMonth - i
  426. const dateStr = formatYMD(year, month - 1, d)
  427. days.push({ key: `prev-${dateStr}`, day: d, otherMonth: true, date: dateStr, disabled: true, isToday: false, isSelected: false })
  428. }
  429. // 当月
  430. for (let d = 1; d <= daysInMonth; d++) {
  431. const dateStr = formatYMD(year, month, d)
  432. const isFuture = dateStr > todayStr
  433. const isSelected = !!currentPending && currentPending === dateStr
  434. days.push({
  435. key: `cur-${dateStr}`, day: d, otherMonth: false, date: dateStr,
  436. disabled: isFuture, isToday: dateStr === todayStr, isSelected,
  437. })
  438. }
  439. // 下月填充至 42 格
  440. let nextDay = 1
  441. while (days.length < 42) {
  442. const dateStr = formatYMD(year, month + 1, nextDay)
  443. days.push({ key: `next-${dateStr}`, day: nextDay, otherMonth: true, date: dateStr, disabled: true, isToday: false, isSelected: false })
  444. nextDay++
  445. }
  446. return days
  447. })
  448. const isNextMonthDisabled = computed(() => {
  449. const today = new Date()
  450. return calendarYear.value > today.getFullYear() ||
  451. (calendarYear.value === today.getFullYear() && calendarMonth.value >= today.getMonth() + 1)
  452. })
  453. function formatYMD(year, month, day) {
  454. const m = month < 0 ? 12 : month > 12 ? 1 : month
  455. return `${year}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`
  456. }
  457. function prevMonth() {
  458. if (calendarMonth.value === 1) { calendarMonth.value = 12; calendarYear.value-- }
  459. else calendarMonth.value--
  460. }
  461. function nextMonth() {
  462. if (isNextMonthDisabled.value) return
  463. if (calendarMonth.value === 12) { calendarMonth.value = 1; calendarYear.value++ }
  464. else calendarMonth.value++
  465. }
  466. function selectDay(day) {
  467. if (day.disabled) return
  468. pendingDay.value = day.date
  469. }
  470. function toggleBirthInfo() {
  471. birthInfoIgnored.value = !birthInfoIgnored.value
  472. syncBirthFields()
  473. }
  474. function toggleBirthTimeUnknown() {
  475. if (birthInfoIgnored.value) return
  476. birthTimeUnknown.value = !birthTimeUnknown.value
  477. if (birthTimeUnknown.value) birthClock.value = ''
  478. syncBirthFields()
  479. }
  480. function openDatePicker() {
  481. if (birthInfoIgnored.value) return
  482. if (birthDate.value) {
  483. const p = birthDate.value.split('-')
  484. calendarYear.value = +p[0]
  485. calendarMonth.value = +p[1]
  486. pendingDay.value = birthDate.value
  487. } else {
  488. calendarYear.value = todayDate.getFullYear()
  489. calendarMonth.value = todayDate.getMonth() + 1
  490. pendingDay.value = null
  491. }
  492. dateOpen.value = true
  493. }
  494. function confirmDate() {
  495. if (pendingDay.value) {
  496. birthDate.value = pendingDay.value
  497. syncBirthFields()
  498. }
  499. dateOpen.value = false
  500. }
  501. function openTimePicker() {
  502. if (birthInfoIgnored.value) return
  503. const clock = birthClock.value || '08:00'
  504. const [h, m] = clock.split(':')
  505. tempTimeValue.value = new Time(+h, +m)
  506. timeOpen.value = true
  507. }
  508. function confirmTime() {
  509. const t = tempTimeValue.value
  510. if (t) {
  511. birthClock.value = `${String(t.hour).padStart(2, '0')}:${String(t.minute).padStart(2, '0')}`
  512. birthTimeUnknown.value = false
  513. syncBirthFields()
  514. }
  515. timeOpen.value = false
  516. }
  517. function timeUnknownPick() {
  518. birthTimeUnknown.value = true
  519. birthClock.value = ''
  520. syncBirthFields()
  521. timeOpen.value = false
  522. }
  523. function syncBirthFields() {
  524. if (birthInfoIgnored.value) {
  525. form.birth_date = ''; form.birth_clock = ''; form.birth_time_unknown = 1
  526. return
  527. }
  528. form.birth_date = birthDate.value || ''
  529. form.birth_clock = birthTimeUnknown.value ? '' : (birthClock.value || '')
  530. form.birth_time_unknown = birthTimeUnknown.value ? 1 : 0
  531. }
  532. const birthClockText = computed(() => {
  533. if (birthTimeUnknown.value) return '不知道具体时间'
  534. return birthClock.value || '请选择时分'
  535. })
  536. const previewName = '白敬亭'
  537. // 农历
  538. const birthLunarText = ref('')
  539. watch(birthDate, async (val) => {
  540. if (!val) { birthLunarText.value = ''; return }
  541. try {
  542. const { Solar } = await import('lunar-javascript')
  543. const p = val.split('-')
  544. const lunar = Solar.fromYmd(+p[0], +p[1], +p[2]).getLunar()
  545. birthLunarText.value = `农历${lunar.getYearInGanZhi()}年 ${lunar.getMonthInChinese()}${lunar.getDayInChinese()}`
  546. } catch { birthLunarText.value = '' }
  547. }, { immediate: true })
  548. // ==================== Billing & Submit ====================
  549. const billing = reactive({ loaded: false, points: 0 })
  550. const balance = reactive({ points: 0 })
  551. async function loadBilling() {
  552. try {
  553. const res = await api.post('/billing/config', { app_key: 'ai-name' })
  554. if (res.code === 0 && res.data) {
  555. billing.points = res.data.quote?.points || res.data.points || 0
  556. if (res.data.balance) balance.points = res.data.balance.points || res.data.balance.point_balance || 0
  557. }
  558. } catch {} finally { billing.loaded = true }
  559. }
  560. async function loadBalance() {
  561. try {
  562. const res = await api.post('/user/getbalance', {})
  563. if (res.code === 0 && res.data) balance.points = res.data.points || res.data.point_balance || 0
  564. } catch {}
  565. }
  566. onMounted(() => { loadBilling(); if (user.isLogin.value) loadBalance() })
  567. const submitting = ref(false)
  568. const questionTitle = ref('')
  569. const canSubmit = computed(() => !!form.surname && !submitting.value)
  570. const balanceText = computed(() => {
  571. const cost = Number(billing.points || 0)
  572. if (!user.isLogin.value) return cost ? `AI取名 · ${cost}点/次` : 'AI取名'
  573. return cost ? `余额${balance.points}点 · 预计消耗${cost}点` : `余额${balance.points}点`
  574. })
  575. const submitText = computed(() => {
  576. const cost = Number(billing.points || 0)
  577. if (!user.isLogin.value) return cost ? `登录后取名 · ${cost}点` : '登录后取名'
  578. return cost ? `立即取名 · ${cost}点` : '立即取名'
  579. })
  580. const isBalanceInsufficient = computed(() => {
  581. const cost = Number(billing.points || 0)
  582. return user.isLogin.value && cost > 0 && balance.points >= 0 && balance.points < cost
  583. })
  584. const resultState = ref({ visible: false, loading: false, error: false, empty: false, success: false, errorMsg: '' })
  585. const result = ref({ summary: '', names: [] })
  586. async function handleSubmit() {
  587. if (!user.isLogin.value) { showToast('请先登录'); return router.push('/login') }
  588. if (!form.surname) { showToast('请输入姓氏'); return }
  589. if (submitting.value) return
  590. syncBirthFields()
  591. submitting.value = true
  592. resultState.value = { visible: true, loading: true, error: false, empty: false, success: false, errorMsg: '' }
  593. result.value = { summary: '', names: [] }
  594. questionTitle.value = `${form.surname}${form.sex === 2 ? '女孩' : form.sex === 1 ? '男孩' : ''}好名字`
  595. const params = {
  596. surname: form.surname, sex: form.sex, name_source: form.name_source,
  597. words_num: form.words_num, is_redup: form.is_redup, special_word: form.special_word,
  598. content: form.content, model: 4,
  599. birth_date: form.birth_date, birth_clock: form.birth_clock, birth_time_unknown: form.birth_time_unknown,
  600. }
  601. try {
  602. const res = await api.post('/ainame/send', params)
  603. if (res.code === 1011) {
  604. resultState.value = { ...resultState.value, loading: false, error: true, errorMsg: '点数不足,请充值后重试' }
  605. return
  606. }
  607. if (res.code !== 0) {
  608. resultState.value = { ...resultState.value, loading: false, error: true, errorMsg: res.msg || '生成失败' }
  609. return
  610. }
  611. const data = res.data || {}
  612. const parsed = parseResult(data.content || data)
  613. if (parsed.names && parsed.names.length > 0) {
  614. result.value = parsed
  615. resultState.value = { visible: true, loading: false, error: false, empty: false, success: true, errorMsg: '' }
  616. } else {
  617. result.value = parsed
  618. resultState.value = { visible: true, loading: false, error: false, empty: true, success: false, errorMsg: '' }
  619. }
  620. } catch (e) {
  621. resultState.value = { ...resultState.value, loading: false, error: true, errorMsg: e.msg || '网络异常,请重试' }
  622. } finally { submitting.value = false }
  623. }
  624. function parseResult(content) {
  625. if (!content) return { summary: '', names: [] }
  626. let text = String(content).trim().replace(/^```json\s*/i, '').replace(/^```\s*/i, '').replace(/```$/i, '').trim()
  627. const line = parseJsonLines(text); if (line.names.length) return line
  628. const marked = parseMarkedText(text); if (marked.names.length) return marked
  629. const s = text.indexOf('{'), e = text.lastIndexOf('}')
  630. if (s >= 0 && e > s) text = text.substring(s, e + 1)
  631. try { const d = JSON.parse(text); return { summary: d.summary || '', names: Array.isArray(d.names) ? d.names : [] } }
  632. catch { return { summary: text, names: [] } }
  633. }
  634. function parseJsonLines(text) {
  635. const d = { summary: '', names: [] }
  636. 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 {} })
  637. return d
  638. }
  639. function parseMarkedText(text) {
  640. const d = { summary: '', names: [] }; const v = String(text || '')
  641. const sm = v.match(/整体建议[::]([\s\S]*?)(?=\n\s*【|$)/); if (sm) d.summary = sm[1].trim()
  642. const reg = /【([^】]+)】([\s\S]*?)(?=\n\s*【|$)/g; let m
  643. while ((m = reg.exec(v))) {
  644. const body = m[2] || '', name = resolveNameBlockTitle(m[1], body)
  645. 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, '推荐') })
  646. }
  647. d.names = d.names.filter(i => i.name).slice(0, 5); return d
  648. }
  649. function resolveNameBlockTitle(title, body) {
  650. const n = String(title || '').trim(); if (!['姓名', '名字', '候选名'].includes(n)) return n
  651. const fr = /^(拼音|评分|出处|五行|寓意|音律|推荐)[::]/; const line = String(body || '').split(/\r?\n/).map(i => i.trim()).find(i => i && !fr.test(i))
  652. return line ? line.replace(/^姓名[::]/, '').trim() : n
  653. }
  654. function mf(body, label) { const r = new RegExp(`${label}[::]([^\\n\\r]*)`); const m = String(body || '').match(r); return m ? m[1].trim() : '' }
  655. function copyResult() {
  656. const names = result.value.names || []
  657. if (!names.length) { showToast('暂无可复制内容'); return }
  658. const lines = []
  659. if (result.value.summary) lines.push(result.value.summary)
  660. names.forEach(item => {
  661. lines.push(`${item.name}${item.score ? `(${item.score}分)` : ''}`)
  662. if (item.pinyin) lines.push(`拼音:${item.pinyin}`)
  663. if (item.source) lines.push(`出处:${item.source}`)
  664. if (item.wuxing) lines.push(`五行:${item.wuxing}`)
  665. if (item.meaning) lines.push(`寓意:${item.meaning}`)
  666. if (item.tone) lines.push(`音律:${item.tone}`)
  667. if (item.reason) lines.push(`推荐:${item.reason}`)
  668. lines.push('')
  669. })
  670. navigator.clipboard.writeText(lines.join('\n')).then(() => showToast('复制成功', 'primary')).catch(() => showToast('复制失败'))
  671. }
  672. function goRecharge() { router.push('/recharge') }
  673. function goHistory() { router.push('/ai-name-history') }
  674. </script>
  675. <style scoped>
  676. .animate-pulse-dot { animation: dotPulse 1.1s ease-in-out infinite; }
  677. .animate-pulse-dot2 { animation: dotPulse 1.1s ease-in-out 0.15s infinite; }
  678. .animate-pulse-dot3 { animation: dotPulse 1.1s ease-in-out 0.3s infinite; }
  679. @keyframes dotPulse {
  680. 0%, 100% { opacity: 0.35; transform: scale(0.82); }
  681. 50% { opacity: 1; transform: scale(1); }
  682. }
  683. .modal-enter-active, .modal-leave-active { transition: opacity 0.2s ease; }
  684. .modal-enter-from, .modal-leave-to { opacity: 0; }
  685. .modal-enter-active > div:last-child, .modal-leave-active > div:last-child { transition: transform 0.2s ease; }
  686. .modal-enter-from > div:last-child, .modal-leave-to > div:last-child { transform: scale(0.95); }
  687. </style>