|
- <template>
- <view class="page">
- <scroll-view class="scroll" scroll-y>
- <view class="wrap">
- <view class="content-card">
- <view v-if="questionTitle" class="question">{{ questionTitle }}</view>
-
- <view v-if="streaming" class="stream-box">
- <view class="loading">生成中...</view>
- <view v-if="!result.names.length" class="progress-card">
- <view class="dot-list">
- <text></text>
- <text></text>
- <text></text>
- </view>
- <text class="progress-title">正在整理名字方案</text>
- <text class="progress-desc">AI 正在分析音律、寓意和出处,完成后会自动展示为名字卡片。</text>
- </view>
- <text v-if="streamText && !result.names.length" class="stream-text" user-select>{{ streamText }}</text>
- </view>
-
- <view v-if="Number(msg.is_suc) < 0" class="error">{{ msg.content || '生成失败' }}</view>
-
- <view v-if="result.names && result.names.length" class="result">
- <text v-if="result.summary" class="summary">{{ result.summary }}</text>
- <view v-for="item in result.names" :key="item.name" class="name-card">
- <view class="name-head">
- <text class="name">{{ item.name }}</text>
- <text v-if="item.score" class="score">{{ item.score }}分</text>
- </view>
- <text v-if="item.pinyin" class="pinyin">{{ item.pinyin }}</text>
- <view v-if="item.source" class="row">
- <text class="row-label">出处</text>
- <text class="row-text">{{ item.source }}</text>
- </view>
- <view v-if="item.wuxing" class="row">
- <text class="row-label">五行</text>
- <text class="row-text">{{ item.wuxing }}</text>
- </view>
- <view v-if="item.meaning" class="row">
- <text class="row-label">寓意</text>
- <text class="row-text">{{ item.meaning }}</text>
- </view>
- <view v-if="item.tone" class="row">
- <text class="row-label">音律</text>
- <text class="row-text">{{ item.tone }}</text>
- </view>
- <view v-if="item.reason" class="row">
- <text class="row-label">推荐</text>
- <text class="row-text">{{ item.reason }}</text>
- </view>
- </view>
- </view>
-
- <view v-if="!streaming && Number(msg.is_suc) >= 0 && (!result.names || !result.names.length)" class="empty-card">
- <text class="empty-title">本次没有可用名字</text>
- <text class="empty-desc">{{ result.summary || 'AI 返回的候选名未通过姓名可用性筛选,请调整条件或重新生成。' }}</text>
- </view>
- </view>
- </view>
- </scroll-view>
-
- <view class="bottom-bar">
- <button class="bar-btn" :loading="regenerating" @click="regenerate">重新生成</button>
- <button class="bar-btn" @click="copy">复制</button>
- <button class="main-btn" @click="continueName">继续AI取名</button>
- </view>
- </view>
- </template>
-
- <script setup>
- import { computed, ref } from 'vue'
- import { onLoad, onUnload } from '@dcloudio/uni-app'
- import dayjs from 'dayjs'
- import { toolApi } from '@/api/index.js'
- import { requestNameStream } from '@/utils/nameStream.js'
- import { useApp } from '@/utils/useApp.js'
-
- const { toast } = useApp()
- const id = ref('')
- const robotId = ref('')
- const msg = ref({})
- const result = ref({ summary: '', names: [] })
- const streamText = ref('')
- const streaming = ref(false)
- const regenerating = ref(false)
- const streamParams = ref({})
- let timer = 0
- let streamTask = null
-
- const questionTitle = computed(() => {
- return msg.value.question_content || (streaming.value ? 'AI取名' : '')
- })
-
- onLoad((options) => {
- robotId.value = options.robot_id || ''
- if (options.create == 1) {
- const params = uni.getStorageSync('name_stream_params')
- if (params && params.surname) {
- startStream(params)
- } else {
- toast('缺少取名参数')
- }
- return
- }
- id.value = options.id || ''
- loadDetail()
- })
-
- onUnload(() => {
- clearTimeout(timer)
- if (streamTask && streamTask.abort) streamTask.abort()
- })
-
- async function loadDetail() {
- if (!id.value) return
- try {
- const res = await toolApi.getNameDetail({ chat_open_id: id.value })
- msg.value = res.data || {}
- streamParams.value = msg.value.detail || {}
- result.value = normalizeResult(parseResult(msg.value.content))
- if (Number(msg.value.is_suc) === 0 || !msg.value.content) {
- timer = setTimeout(loadDetail, 2000)
- }
- } catch (e) {
- toast(e.msg || '结果加载失败')
- }
- }
-
- function startStream(params) {
- streamParams.value = params || {}
- streaming.value = true
- streamText.value = ''
- result.value = { summary: '', names: [] }
- msg.value = {
- question_content: `${params.surname}${params.sex == 2 ? '女孩' : '男孩'}好名字`
- }
-
- streamTask = requestNameStream(params, {
- onStart: (data) => {
- id.value = data.answer_chat_open_id || ''
- },
- onMessage: (data) => {
- if (data.type === 'summary') {
- result.value.summary = data.content || ''
- return
- }
- if (data.type === 'name' && data.data) {
- if (!isValidName(data.data.name)) return
- const exists = result.value.names.some((item) => item.name === data.data.name)
- if (!exists) result.value.names.push(data.data)
- return
- }
- if (data.content) {
- streamText.value += data.content
- const parsed = parseResult(streamText.value)
- if (parsed.summary) result.value.summary = parsed.summary
- parsed.names = normalizeNames(parsed.names)
- if (parsed.names.length) result.value.names = parsed.names
- }
- },
- onDone: (data) => {
- streaming.value = false
- streamText.value = data.content || streamText.value
- result.value = normalizeResult(data.result || parseResult(streamText.value))
- msg.value = {
- ...msg.value,
- is_suc: 1,
- content: JSON.stringify(result.value)
- }
- uni.removeStorageSync('name_stream_params')
- },
- onError: (data) => {
- streaming.value = false
- msg.value = { ...msg.value, is_suc: -1, content: data.msg || '生成失败' }
- toast(data.msg || '生成失败')
- }
- })
- }
-
- function parseResult(content) {
- if (!content) return { summary: '', names: [] }
- let text = String(content).trim()
- text = text.replace(/^```json\s*/i, '').replace(/^```\s*/i, '').replace(/```$/i, '').trim()
-
- const lineResult = parseJsonLines(text)
- if (lineResult.names.length) return lineResult
-
- const markedResult = parseMarkedText(text)
- if (markedResult.names.length) return markedResult
-
- const start = text.indexOf('{')
- const end = text.lastIndexOf('}')
- if (start >= 0 && end > start) text = text.substring(start, end + 1)
-
- try {
- const data = JSON.parse(text)
- return {
- summary: data.summary || '',
- names: Array.isArray(data.names) ? data.names : []
- }
- } catch (e) {
- return { summary: text, names: [] }
- }
- }
-
- function parseMarkedText(text) {
- const data = { summary: '', names: [] }
- const value = String(text || '')
- const summaryMatch = value.match(/整体建议[::]([\s\S]*?)(?=\n\s*【|$)/)
- if (summaryMatch) data.summary = summaryMatch[1].trim()
-
- const reg = /【([^】]+)】([\s\S]*?)(?=\n\s*【|$)/g
- let match
- while ((match = reg.exec(value))) {
- const body = match[2] || ''
- const name = resolveNameBlockTitle(match[1], body)
- data.names.push({
- name,
- pinyin: matchField(body, '拼音'),
- score: parseInt(matchField(body, '评分')) || 90,
- source: matchField(body, '出处'),
- wuxing: matchField(body, '五行'),
- meaning: matchField(body, '寓意'),
- tone: matchField(body, '音律'),
- reason: matchField(body, '推荐')
- })
- }
- data.names = data.names.filter((item) => item.name).slice(0, 5)
- return data
- }
-
- function resolveNameBlockTitle(title, body) {
- const name = String(title || '').trim()
- if (!['姓名', '名字', '候选名'].includes(name)) return name
-
- const fieldReg = /^(拼音|评分|出处|五行|寓意|音律|推荐)[::]/
- const line = String(body || '').split(/\r?\n/).map((item) => item.trim()).find((item) => item && !fieldReg.test(item))
- return line ? line.replace(/^姓名[::]/, '').trim() : name
- }
-
- function matchField(body, label) {
- const reg = new RegExp(`${label}[::]([^\\n\\r]*)`)
- const match = String(body || '').match(reg)
- return match ? match[1].trim() : ''
- }
-
- function parseJsonLines(text) {
- const data = { summary: '', names: [] }
- String(text || '').split(/\r?\n/).forEach((line) => {
- line = line.trim()
- if (!line) return
- try {
- const item = JSON.parse(line)
- if (item.type === 'summary') data.summary = item.content || ''
- if (item.type === 'name' && item.data) data.names.push(item.data)
- } catch (e) {}
- })
- return data
- }
-
- function normalizeResult(data) {
- data = data || { summary: '', names: [] }
- return {
- ...data,
- names: normalizeNames(data.names)
- }
- }
-
- function normalizeNames(names) {
- return (Array.isArray(names) ? names : []).filter((item) => isValidName(item.name)).slice(0, 5)
- }
-
- function isValidName(name) {
- const params = streamParams.value || {}
- const value = String(name || '').replace(/\s/g, '')
- const surname = String(params.surname || '').trim()
- const specialWord = String(params.special_word || '').trim()
- const wordsNum = Number(params.words_num || 0)
-
- if (!value) return false
- if (!surname && !specialWord && wordsNum <= 0) return true
- if (surname && !value.startsWith(surname)) return false
- if (specialWord && !value.includes(specialWord)) return false
- if (wordsNum > 0 && Array.from(value).length !== wordsNum) return false
- if (!isHumanLikeName(value, surname)) return false
- return true
- }
-
- function isHumanLikeName(name, surname) {
- const value = String(name || '').replace(/\s/g, '')
- const familyName = String(surname || '').trim()
- if (!value || !familyName || !value.startsWith(familyName)) return false
-
- const givenName = Array.from(value).slice(Array.from(familyName).length).join('')
- if (!givenName || givenName.length > 3) return false
-
- const bannedFullNames = ['雷厉风行', '雷打不动', '雷腾云奔', '雷令风行']
- if (bannedFullNames.includes(value)) return false
-
- const phrasePatterns = ['厉风行', '打不动', '腾云奔', '令风行', '风行', '不动', '云奔', '令行']
- if (phrasePatterns.some((item) => givenName.includes(item))) return false
-
- const unnaturalChars = ['打', '不']
- if (unnaturalChars.some((item) => givenName.includes(item))) return false
-
- const unnaturalEndChars = ['动', '奔', '行']
- if (unnaturalEndChars.includes(givenName[givenName.length - 1])) return false
-
- return true
- }
-
- function formatCopyText() {
- if (result.value.names && result.value.names.length) {
- const lines = []
- if (result.value.summary) lines.push(result.value.summary)
- result.value.names.forEach((item) => {
- lines.push(`${item.name}${item.score ? `(${item.score}分)` : ''}`)
- if (item.pinyin) lines.push(`拼音:${item.pinyin}`)
- if (item.source) lines.push(`出处:${item.source}`)
- if (item.wuxing) lines.push(`五行:${item.wuxing}`)
- if (item.meaning) lines.push(`寓意:${item.meaning}`)
- if (item.tone) lines.push(`音律:${item.tone}`)
- if (item.reason) lines.push(`推荐:${item.reason}`)
- lines.push('')
- })
- return lines.join('\n')
- }
- return streamText.value || msg.value.content || ''
- }
-
- function copy() {
- const data = formatCopyText()
- if (!data) return toast('暂无可复制内容')
- uni.setClipboardData({
- data,
- success: () => toast('复制成功')
- })
- }
-
- function regenerate() {
- const cache = uni.getStorageSync('writing_form')
- if (!cache || !cache.surname) {
- continueName()
- return
- }
- regenerating.value = true
- const params = {
- ...cache,
- model: 4,
- robot_open_id: robotId.value
- }
- if (params.birth_time) params.birth_time = dayjs(params.birth_time).unix()
- startStream(params)
- regenerating.value = false
- }
-
- function continueName() {
- const pages = getCurrentPages()
- const targetIndex = pages.findIndex((page) => page.route === 'pages/tool/ai-name')
- if (targetIndex >= 0) {
- const delta = pages.length - 1 - targetIndex
- if (delta > 0) {
- uni.navigateBack({ delta })
- return
- }
- }
- uni.redirectTo({ url: '/pages/tool/ai-name' })
- }
- </script>
-
- <style lang="scss" scoped>
- .page {
- height: 100vh;
- background: #f4f7fb;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
-
- .scroll {
- flex: 1;
- height: 0;
- }
-
- .wrap {
- padding: 28rpx 28rpx 220rpx;
- }
-
- .content-card {
- min-height: 70vh;
- padding: 34rpx;
- border-radius: 18rpx;
- background: #fff;
- box-sizing: border-box;
- }
-
- .question {
- color: #172033;
- font-size: 32rpx;
- font-weight: 700;
- line-height: 1.5;
- margin-bottom: 28rpx;
- }
-
- .loading,
- .empty {
- color: #7f8896;
- font-size: 28rpx;
- }
-
- .progress-card {
- margin-top: 20rpx;
- padding: 34rpx 28rpx;
- border-radius: 16rpx;
- background: #f7f9fd;
- display: flex;
- flex-direction: column;
- align-items: center;
- text-align: center;
- }
-
- .dot-list {
- display: flex;
- gap: 10rpx;
- margin-bottom: 20rpx;
-
- text {
- width: 14rpx;
- height: 14rpx;
- border-radius: 50%;
- background: #0b8cff;
- animation: pulse 1.1s ease-in-out infinite;
-
- &:nth-child(2) {
- animation-delay: 0.15s;
- }
-
- &:nth-child(3) {
- animation-delay: 0.3s;
- }
- }
- }
-
- .progress-title {
- color: #172033;
- font-size: 30rpx;
- font-weight: 700;
- }
-
- .progress-desc {
- margin-top: 14rpx;
- color: #7f8896;
- font-size: 25rpx;
- line-height: 1.6;
- }
-
- .stream-text {
- display: block;
- margin-top: 22rpx;
- padding: 24rpx;
- border-radius: 14rpx;
- color: #172033;
- font-size: 26rpx;
- line-height: 1.7;
- white-space: pre-wrap;
- background: #f7f9fd;
- }
-
- .error {
- color: #f04438;
- font-size: 28rpx;
- }
-
- .empty-card {
- padding: 34rpx 30rpx;
- border-radius: 16rpx;
- background: #f7f9fd;
- }
-
- .empty-title {
- display: block;
- color: #172033;
- font-size: 30rpx;
- font-weight: 700;
- }
-
- .empty-desc {
- display: block;
- margin-top: 16rpx;
- color: #7f8896;
- font-size: 26rpx;
- line-height: 1.6;
- }
-
- .summary {
- display: block;
- color: #596579;
- font-size: 27rpx;
- line-height: 1.6;
- margin-bottom: 22rpx;
- }
-
- .name-card {
- padding: 26rpx;
- border-radius: 16rpx;
- background: #f7f9fd;
- margin-top: 20rpx;
- }
-
- .name-head {
- display: flex;
- align-items: center;
- }
-
- .name {
- color: #0b8cff;
- font-size: 42rpx;
- font-weight: 700;
- }
-
- .score {
- margin-left: auto;
- color: #ff8a00;
- font-size: 28rpx;
- font-weight: 700;
- }
-
- .pinyin {
- display: block;
- margin-top: 6rpx;
- color: #7f8896;
- font-size: 24rpx;
- }
-
- .row {
- margin-top: 16rpx;
- display: flex;
- align-items: flex-start;
- }
-
- .row-label {
- width: 78rpx;
- height: 38rpx;
- border-radius: 20rpx;
- color: #0b8cff;
- font-size: 22rpx;
- line-height: 38rpx;
- text-align: center;
- background: #eef6ff;
- flex-shrink: 0;
- }
-
- .row-text {
- flex: 1;
- min-width: 0;
- margin-left: 14rpx;
- color: #172033;
- font-size: 26rpx;
- line-height: 1.55;
- }
-
- .bottom-bar {
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 50;
- padding: 18rpx 28rpx calc(18rpx + env(safe-area-inset-bottom));
- background: #fff;
- display: flex;
- gap: 18rpx;
- box-shadow: 0 -8rpx 24rpx rgba(35, 55, 90, 0.08);
- }
-
- @keyframes pulse {
- 0%,
- 100% {
- opacity: 0.35;
- transform: scale(0.82);
- }
-
- 50% {
- opacity: 1;
- transform: scale(1);
- }
- }
-
- .bar-btn,
- .main-btn {
- height: 82rpx;
- padding: 0;
- border: 0;
- outline: 0;
- border-radius: 42rpx;
- line-height: 82rpx;
- font-size: 29rpx;
- margin: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- box-sizing: border-box;
- overflow: hidden;
- background-clip: padding-box;
- -webkit-appearance: none;
- appearance: none;
- }
-
- .bar-btn::after,
- .main-btn::after {
- border: 0;
- }
-
- .bar-btn {
- width: 170rpx;
- color: #0b8cff;
- background: #eef6ff;
- }
-
- .main-btn {
- flex: 1;
- color: #fff;
- background: #0b8cff;
- }
- </style>
|