Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.
 
 
 

610 lignes
16 KiB

  1. <template>
  2. <view class="page">
  3. <scroll-view class="scroll" scroll-y>
  4. <view class="wrap">
  5. <view class="content-card">
  6. <view v-if="questionTitle" class="question">{{ questionTitle }}</view>
  7. <view v-if="streaming" class="stream-box">
  8. <view class="loading">生成中...</view>
  9. <view v-if="!result.names.length" class="progress-card">
  10. <view class="dot-list">
  11. <text></text>
  12. <text></text>
  13. <text></text>
  14. </view>
  15. <text class="progress-title">正在整理名字方案</text>
  16. <text class="progress-desc">AI 正在分析音律、寓意和出处,完成后会自动展示为名字卡片。</text>
  17. </view>
  18. <text v-if="streamText && !result.names.length" class="stream-text" user-select>{{ streamText }}</text>
  19. </view>
  20. <view v-if="Number(msg.is_suc) < 0" class="error">{{ msg.content || '生成失败' }}</view>
  21. <view v-if="result.names && result.names.length" class="result">
  22. <text v-if="result.summary" class="summary">{{ result.summary }}</text>
  23. <view v-for="item in result.names" :key="item.name" class="name-card">
  24. <view class="name-head">
  25. <text class="name">{{ item.name }}</text>
  26. <text v-if="item.score" class="score">{{ item.score }}分</text>
  27. </view>
  28. <text v-if="item.pinyin" class="pinyin">{{ item.pinyin }}</text>
  29. <view v-if="item.source" class="row">
  30. <text class="row-label">出处</text>
  31. <text class="row-text">{{ item.source }}</text>
  32. </view>
  33. <view v-if="item.wuxing" class="row">
  34. <text class="row-label">五行</text>
  35. <text class="row-text">{{ item.wuxing }}</text>
  36. </view>
  37. <view v-if="item.meaning" class="row">
  38. <text class="row-label">寓意</text>
  39. <text class="row-text">{{ item.meaning }}</text>
  40. </view>
  41. <view v-if="item.tone" class="row">
  42. <text class="row-label">音律</text>
  43. <text class="row-text">{{ item.tone }}</text>
  44. </view>
  45. <view v-if="item.reason" class="row">
  46. <text class="row-label">推荐</text>
  47. <text class="row-text">{{ item.reason }}</text>
  48. </view>
  49. </view>
  50. </view>
  51. <view v-if="!streaming && Number(msg.is_suc) >= 0 && (!result.names || !result.names.length)" class="empty-card">
  52. <text class="empty-title">本次没有可用名字</text>
  53. <text class="empty-desc">{{ result.summary || 'AI 返回的候选名未通过姓名可用性筛选,请调整条件或重新生成。' }}</text>
  54. </view>
  55. </view>
  56. </view>
  57. </scroll-view>
  58. <view class="bottom-bar">
  59. <button class="bar-btn" :loading="regenerating" @click="regenerate">重新生成</button>
  60. <button class="bar-btn" @click="copy">复制</button>
  61. <button class="main-btn" @click="continueName">继续AI取名</button>
  62. </view>
  63. </view>
  64. </template>
  65. <script setup>
  66. import { computed, ref } from 'vue'
  67. import { onLoad, onUnload } from '@dcloudio/uni-app'
  68. import dayjs from 'dayjs'
  69. import { toolApi } from '@/api/index.js'
  70. import { requestNameStream } from '@/utils/nameStream.js'
  71. import { useApp } from '@/utils/useApp.js'
  72. const { toast } = useApp()
  73. const id = ref('')
  74. const robotId = ref('')
  75. const msg = ref({})
  76. const result = ref({ summary: '', names: [] })
  77. const streamText = ref('')
  78. const streaming = ref(false)
  79. const regenerating = ref(false)
  80. const streamParams = ref({})
  81. let timer = 0
  82. let streamTask = null
  83. const questionTitle = computed(() => {
  84. return msg.value.question_content || (streaming.value ? 'AI取名' : '')
  85. })
  86. onLoad((options) => {
  87. robotId.value = options.robot_id || ''
  88. if (options.create == 1) {
  89. const params = uni.getStorageSync('name_stream_params')
  90. if (params && params.surname) {
  91. startStream(params)
  92. } else {
  93. toast('缺少取名参数')
  94. }
  95. return
  96. }
  97. id.value = options.id || ''
  98. loadDetail()
  99. })
  100. onUnload(() => {
  101. clearTimeout(timer)
  102. if (streamTask && streamTask.abort) streamTask.abort()
  103. })
  104. async function loadDetail() {
  105. if (!id.value) return
  106. try {
  107. const res = await toolApi.getNameDetail({ chat_open_id: id.value })
  108. msg.value = res.data || {}
  109. streamParams.value = msg.value.detail || {}
  110. result.value = normalizeResult(parseResult(msg.value.content))
  111. if (Number(msg.value.is_suc) === 0 || !msg.value.content) {
  112. timer = setTimeout(loadDetail, 2000)
  113. }
  114. } catch (e) {
  115. toast(e.msg || '结果加载失败')
  116. }
  117. }
  118. function startStream(params) {
  119. streamParams.value = params || {}
  120. streaming.value = true
  121. streamText.value = ''
  122. result.value = { summary: '', names: [] }
  123. msg.value = {
  124. question_content: `${params.surname}${params.sex == 2 ? '女孩' : '男孩'}好名字`
  125. }
  126. streamTask = requestNameStream(params, {
  127. onStart: (data) => {
  128. id.value = data.answer_chat_open_id || ''
  129. },
  130. onMessage: (data) => {
  131. if (data.type === 'summary') {
  132. result.value.summary = data.content || ''
  133. return
  134. }
  135. if (data.type === 'name' && data.data) {
  136. if (!isValidName(data.data.name)) return
  137. const exists = result.value.names.some((item) => item.name === data.data.name)
  138. if (!exists) result.value.names.push(data.data)
  139. return
  140. }
  141. if (data.content) {
  142. streamText.value += data.content
  143. const parsed = parseResult(streamText.value)
  144. if (parsed.summary) result.value.summary = parsed.summary
  145. parsed.names = normalizeNames(parsed.names)
  146. if (parsed.names.length) result.value.names = parsed.names
  147. }
  148. },
  149. onDone: (data) => {
  150. streaming.value = false
  151. streamText.value = data.content || streamText.value
  152. result.value = normalizeResult(data.result || parseResult(streamText.value))
  153. msg.value = {
  154. ...msg.value,
  155. is_suc: 1,
  156. content: JSON.stringify(result.value)
  157. }
  158. uni.removeStorageSync('name_stream_params')
  159. },
  160. onError: (data) => {
  161. streaming.value = false
  162. msg.value = { ...msg.value, is_suc: -1, content: data.msg || '生成失败' }
  163. toast(data.msg || '生成失败')
  164. }
  165. })
  166. }
  167. function parseResult(content) {
  168. if (!content) return { summary: '', names: [] }
  169. let text = String(content).trim()
  170. text = text.replace(/^```json\s*/i, '').replace(/^```\s*/i, '').replace(/```$/i, '').trim()
  171. const lineResult = parseJsonLines(text)
  172. if (lineResult.names.length) return lineResult
  173. const markedResult = parseMarkedText(text)
  174. if (markedResult.names.length) return markedResult
  175. const start = text.indexOf('{')
  176. const end = text.lastIndexOf('}')
  177. if (start >= 0 && end > start) text = text.substring(start, end + 1)
  178. try {
  179. const data = JSON.parse(text)
  180. return {
  181. summary: data.summary || '',
  182. names: Array.isArray(data.names) ? data.names : []
  183. }
  184. } catch (e) {
  185. return { summary: text, names: [] }
  186. }
  187. }
  188. function parseMarkedText(text) {
  189. const data = { summary: '', names: [] }
  190. const value = String(text || '')
  191. const summaryMatch = value.match(/整体建议[::]([\s\S]*?)(?=\n\s*【|$)/)
  192. if (summaryMatch) data.summary = summaryMatch[1].trim()
  193. const reg = /【([^】]+)】([\s\S]*?)(?=\n\s*【|$)/g
  194. let match
  195. while ((match = reg.exec(value))) {
  196. const body = match[2] || ''
  197. const name = resolveNameBlockTitle(match[1], body)
  198. data.names.push({
  199. name,
  200. pinyin: matchField(body, '拼音'),
  201. score: parseInt(matchField(body, '评分')) || 90,
  202. source: matchField(body, '出处'),
  203. wuxing: matchField(body, '五行'),
  204. meaning: matchField(body, '寓意'),
  205. tone: matchField(body, '音律'),
  206. reason: matchField(body, '推荐')
  207. })
  208. }
  209. data.names = data.names.filter((item) => item.name).slice(0, 5)
  210. return data
  211. }
  212. function resolveNameBlockTitle(title, body) {
  213. const name = String(title || '').trim()
  214. if (!['姓名', '名字', '候选名'].includes(name)) return name
  215. const fieldReg = /^(拼音|评分|出处|五行|寓意|音律|推荐)[::]/
  216. const line = String(body || '').split(/\r?\n/).map((item) => item.trim()).find((item) => item && !fieldReg.test(item))
  217. return line ? line.replace(/^姓名[::]/, '').trim() : name
  218. }
  219. function matchField(body, label) {
  220. const reg = new RegExp(`${label}[::]([^\\n\\r]*)`)
  221. const match = String(body || '').match(reg)
  222. return match ? match[1].trim() : ''
  223. }
  224. function parseJsonLines(text) {
  225. const data = { summary: '', names: [] }
  226. String(text || '').split(/\r?\n/).forEach((line) => {
  227. line = line.trim()
  228. if (!line) return
  229. try {
  230. const item = JSON.parse(line)
  231. if (item.type === 'summary') data.summary = item.content || ''
  232. if (item.type === 'name' && item.data) data.names.push(item.data)
  233. } catch (e) {}
  234. })
  235. return data
  236. }
  237. function normalizeResult(data) {
  238. data = data || { summary: '', names: [] }
  239. return {
  240. ...data,
  241. names: normalizeNames(data.names)
  242. }
  243. }
  244. function normalizeNames(names) {
  245. return (Array.isArray(names) ? names : []).filter((item) => isValidName(item.name)).slice(0, 5)
  246. }
  247. function isValidName(name) {
  248. const params = streamParams.value || {}
  249. const value = String(name || '').replace(/\s/g, '')
  250. const surname = String(params.surname || '').trim()
  251. const specialWord = String(params.special_word || '').trim()
  252. const wordsNum = Number(params.words_num || 0)
  253. if (!value) return false
  254. if (!surname && !specialWord && wordsNum <= 0) return true
  255. if (surname && !value.startsWith(surname)) return false
  256. if (specialWord && !value.includes(specialWord)) return false
  257. if (wordsNum > 0 && Array.from(value).length !== wordsNum) return false
  258. if (!isHumanLikeName(value, surname)) return false
  259. return true
  260. }
  261. function isHumanLikeName(name, surname) {
  262. const value = String(name || '').replace(/\s/g, '')
  263. const familyName = String(surname || '').trim()
  264. if (!value || !familyName || !value.startsWith(familyName)) return false
  265. const givenName = Array.from(value).slice(Array.from(familyName).length).join('')
  266. if (!givenName || givenName.length > 3) return false
  267. const bannedFullNames = ['雷厉风行', '雷打不动', '雷腾云奔', '雷令风行']
  268. if (bannedFullNames.includes(value)) return false
  269. const phrasePatterns = ['厉风行', '打不动', '腾云奔', '令风行', '风行', '不动', '云奔', '令行']
  270. if (phrasePatterns.some((item) => givenName.includes(item))) return false
  271. const unnaturalChars = ['打', '不']
  272. if (unnaturalChars.some((item) => givenName.includes(item))) return false
  273. const unnaturalEndChars = ['动', '奔', '行']
  274. if (unnaturalEndChars.includes(givenName[givenName.length - 1])) return false
  275. return true
  276. }
  277. function formatCopyText() {
  278. if (result.value.names && result.value.names.length) {
  279. const lines = []
  280. if (result.value.summary) lines.push(result.value.summary)
  281. result.value.names.forEach((item) => {
  282. lines.push(`${item.name}${item.score ? `(${item.score}分)` : ''}`)
  283. if (item.pinyin) lines.push(`拼音:${item.pinyin}`)
  284. if (item.source) lines.push(`出处:${item.source}`)
  285. if (item.wuxing) lines.push(`五行:${item.wuxing}`)
  286. if (item.meaning) lines.push(`寓意:${item.meaning}`)
  287. if (item.tone) lines.push(`音律:${item.tone}`)
  288. if (item.reason) lines.push(`推荐:${item.reason}`)
  289. lines.push('')
  290. })
  291. return lines.join('\n')
  292. }
  293. return streamText.value || msg.value.content || ''
  294. }
  295. function copy() {
  296. const data = formatCopyText()
  297. if (!data) return toast('暂无可复制内容')
  298. uni.setClipboardData({
  299. data,
  300. success: () => toast('复制成功')
  301. })
  302. }
  303. function regenerate() {
  304. const cache = uni.getStorageSync('writing_form')
  305. if (!cache || !cache.surname) {
  306. continueName()
  307. return
  308. }
  309. regenerating.value = true
  310. const params = {
  311. ...cache,
  312. model: 4,
  313. robot_open_id: robotId.value
  314. }
  315. if (params.birth_time) params.birth_time = dayjs(params.birth_time).unix()
  316. startStream(params)
  317. regenerating.value = false
  318. }
  319. function continueName() {
  320. const pages = getCurrentPages()
  321. const targetIndex = pages.findIndex((page) => page.route === 'pages/tool/ai-name')
  322. if (targetIndex >= 0) {
  323. const delta = pages.length - 1 - targetIndex
  324. if (delta > 0) {
  325. uni.navigateBack({ delta })
  326. return
  327. }
  328. }
  329. uni.redirectTo({ url: '/pages/tool/ai-name' })
  330. }
  331. </script>
  332. <style lang="scss" scoped>
  333. .page {
  334. height: 100vh;
  335. background: #f4f7fb;
  336. display: flex;
  337. flex-direction: column;
  338. overflow: hidden;
  339. }
  340. .scroll {
  341. flex: 1;
  342. height: 0;
  343. }
  344. .wrap {
  345. padding: 28rpx 28rpx 220rpx;
  346. }
  347. .content-card {
  348. min-height: 70vh;
  349. padding: 34rpx;
  350. border-radius: 18rpx;
  351. background: #fff;
  352. box-sizing: border-box;
  353. }
  354. .question {
  355. color: #172033;
  356. font-size: 32rpx;
  357. font-weight: 700;
  358. line-height: 1.5;
  359. margin-bottom: 28rpx;
  360. }
  361. .loading,
  362. .empty {
  363. color: #7f8896;
  364. font-size: 28rpx;
  365. }
  366. .progress-card {
  367. margin-top: 20rpx;
  368. padding: 34rpx 28rpx;
  369. border-radius: 16rpx;
  370. background: #f7f9fd;
  371. display: flex;
  372. flex-direction: column;
  373. align-items: center;
  374. text-align: center;
  375. }
  376. .dot-list {
  377. display: flex;
  378. gap: 10rpx;
  379. margin-bottom: 20rpx;
  380. text {
  381. width: 14rpx;
  382. height: 14rpx;
  383. border-radius: 50%;
  384. background: #0b8cff;
  385. animation: pulse 1.1s ease-in-out infinite;
  386. &:nth-child(2) {
  387. animation-delay: 0.15s;
  388. }
  389. &:nth-child(3) {
  390. animation-delay: 0.3s;
  391. }
  392. }
  393. }
  394. .progress-title {
  395. color: #172033;
  396. font-size: 30rpx;
  397. font-weight: 700;
  398. }
  399. .progress-desc {
  400. margin-top: 14rpx;
  401. color: #7f8896;
  402. font-size: 25rpx;
  403. line-height: 1.6;
  404. }
  405. .stream-text {
  406. display: block;
  407. margin-top: 22rpx;
  408. padding: 24rpx;
  409. border-radius: 14rpx;
  410. color: #172033;
  411. font-size: 26rpx;
  412. line-height: 1.7;
  413. white-space: pre-wrap;
  414. background: #f7f9fd;
  415. }
  416. .error {
  417. color: #f04438;
  418. font-size: 28rpx;
  419. }
  420. .empty-card {
  421. padding: 34rpx 30rpx;
  422. border-radius: 16rpx;
  423. background: #f7f9fd;
  424. }
  425. .empty-title {
  426. display: block;
  427. color: #172033;
  428. font-size: 30rpx;
  429. font-weight: 700;
  430. }
  431. .empty-desc {
  432. display: block;
  433. margin-top: 16rpx;
  434. color: #7f8896;
  435. font-size: 26rpx;
  436. line-height: 1.6;
  437. }
  438. .summary {
  439. display: block;
  440. color: #596579;
  441. font-size: 27rpx;
  442. line-height: 1.6;
  443. margin-bottom: 22rpx;
  444. }
  445. .name-card {
  446. padding: 26rpx;
  447. border-radius: 16rpx;
  448. background: #f7f9fd;
  449. margin-top: 20rpx;
  450. }
  451. .name-head {
  452. display: flex;
  453. align-items: center;
  454. }
  455. .name {
  456. color: #0b8cff;
  457. font-size: 42rpx;
  458. font-weight: 700;
  459. }
  460. .score {
  461. margin-left: auto;
  462. color: #ff8a00;
  463. font-size: 28rpx;
  464. font-weight: 700;
  465. }
  466. .pinyin {
  467. display: block;
  468. margin-top: 6rpx;
  469. color: #7f8896;
  470. font-size: 24rpx;
  471. }
  472. .row {
  473. margin-top: 16rpx;
  474. display: flex;
  475. align-items: flex-start;
  476. }
  477. .row-label {
  478. width: 78rpx;
  479. height: 38rpx;
  480. border-radius: 20rpx;
  481. color: #0b8cff;
  482. font-size: 22rpx;
  483. line-height: 38rpx;
  484. text-align: center;
  485. background: #eef6ff;
  486. flex-shrink: 0;
  487. }
  488. .row-text {
  489. flex: 1;
  490. min-width: 0;
  491. margin-left: 14rpx;
  492. color: #172033;
  493. font-size: 26rpx;
  494. line-height: 1.55;
  495. }
  496. .bottom-bar {
  497. position: fixed;
  498. left: 0;
  499. right: 0;
  500. bottom: 0;
  501. z-index: 50;
  502. padding: 18rpx 28rpx calc(18rpx + env(safe-area-inset-bottom));
  503. background: #fff;
  504. display: flex;
  505. gap: 18rpx;
  506. box-shadow: 0 -8rpx 24rpx rgba(35, 55, 90, 0.08);
  507. }
  508. @keyframes pulse {
  509. 0%,
  510. 100% {
  511. opacity: 0.35;
  512. transform: scale(0.82);
  513. }
  514. 50% {
  515. opacity: 1;
  516. transform: scale(1);
  517. }
  518. }
  519. .bar-btn,
  520. .main-btn {
  521. height: 82rpx;
  522. border-radius: 42rpx;
  523. line-height: 82rpx;
  524. font-size: 29rpx;
  525. margin: 0;
  526. }
  527. .bar-btn {
  528. width: 170rpx;
  529. color: #0b8cff;
  530. background: #eef6ff;
  531. }
  532. .main-btn {
  533. flex: 1;
  534. color: #fff;
  535. background: #0b8cff;
  536. }
  537. </style>