topfans/frontend/pages/ai-dazi/index.vue
2026-07-04 00:22:05 +08:00

792 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="ai-dazi-container">
<!-- 背景图 -->
<image class="bg-image" src="/static/AIimg/beijing.png" mode="aspectFill" />
<!-- 左上角关闭按钮 -->
<view class="close-btn" @click="handleClose">
<text class="nav-back" :style="{ color: backIconColor }"></text>
</view>
<!-- 功能按钮区域 -->
<view class="action-buttons">
<!-- 装扮按钮 -->
<!-- <view class="action-btn dressup-btn" @click="handleDressup">
<image class="btn-icon" src="/static/AIimg/zhuanban.png" mode="aspectFit" />
<view class="btn-label-wrap">
<image class="btn-label-bg" src="/static/nft/dingbutubiao_an.png" mode="aspectFit" />
<text class="btn-label-text">装扮</text>
</view>
</view> -->
<!-- 场景按钮 -->
<!-- <view class="action-btn scene-btn" @click="handleScene">
<image class="btn-icon" src="/static/AIimg/changjing.png" mode="aspectFit" />
<view class="btn-label-wrap">
<image class="btn-label-bg" src="/static/nft/dingbutubiao_an.png" mode="aspectFit" />
<text class="btn-label-text">场景</text>
</view>
</view> -->
<!-- 追星历程按钮 -->
<!-- <view class="action-btn history-btn" @click="handleHistory">
<image class="btn-icon" src="/static/AIimg/zhuixinglicheng.png" mode="aspectFit" />
<view class="btn-label-wrap">
<image class="btn-label-bg" src="/static/nft/dingbutubiao_an.png" mode="aspectFit" />
<text class="btn-label-text">追星历程</text>
</view>
</view> -->
</view>
<!-- AI角色对话气泡 -->
<view class="dialog-area" v-if="aiMessage">
<!-- <view class="dialog-bubble">
<image class="bubble-bg" src="/static/AIimg/duihuakuang.png" mode="widthFix" style="width: 320rpx;" />
<text class="bubble-text">{{ aiMessage }}</text>
</view> -->
</view>
<!-- AI角色毛绒小怪兽占位区域 -->
<view class="character-area">
<!-- 角色图片如有可替换为实际角色图 -->
<!-- <view class="character-placeholder">
<text class="character-emoji">🐾</text>
</view> -->
</view>
<!-- 聊天消息区域 -->
<scroll-view class="chat-messages" scroll-y :scroll-top="scrollTop" :scroll-into-view="scrollIntoViewId"
:show-scrollbar="false" scroll-with-animation>
<view v-for="(msg, index) in messages" :key="index" :class="['message-item', msg.role]">
<view v-if="msg.role === 'assistant'" class="message-bubble assistant-bubble">
<text class="message-text">{{ msg.content }}</text>
</view>
<view v-else class="message-bubble user-bubble">
<text class="message-text">{{ msg.content }}</text>
</view>
</view>
<view v-if="isTyping" class="message-item assistant">
<view class="message-bubble assistant-bubble">
<text class="message-text typing">...</text>
</view>
</view>
<view id="scroll-bottom-view"></view>
</scroll-view>
<!-- 底部输入框 -->
<view class="bottom-bar">
<view class="input-wrapper">
<input class="chat-input" v-model="inputText" placeholder="发送消息给角角"
placeholder-class="input-placeholder" confirm-type="send" @confirm="handleSend"
:disabled="isTyping" />
<view class="send-btn" :class="{ 'send-btn-disabled': !inputText.trim() || isTyping }"
@click="inputText.trim() && !isTyping && handleSend()">
<text class="send-icon">发送</text>
</view>
</view>
</view>
</view>
</template>
<script>
import { getAiChatSocket, closeAiChatSocket, isAiChatClosing, resetAiChatClosing } from '@/utils/socket'
export default {
data() {
return {
inputText: '',
messages: [],
aiMessage: '亲爱的你来辣 ~~',
isTyping: false,
scrollTop: 0,
scrollIntoViewId: '',
sessionId: '',
backIconColor: '#fff',
currentAssistantMessage: '',
streamTimeout: null,
streamTimeoutMs: 60000, // 60秒超时
streamTimedOut: false, // 超时后忽略后续 token
initialized: false, // 首次初始化标记
isErrorProcessing: false // 防止错误重复触发
}
},
onLoad() {
this.initSession()
},
onReady() {
console.log('[AI Chat] onReady called')
},
onShow() {
// 页面可见时连接 WebSocket包括首次进入和从其他页面返回
this.initWebSocket()
},
onHide() {
// 页面不可见时断开,不再占用连接
this.clearStreamTimeout()
this.closeWebSocket()
},
onUnload() {
this.clearStreamTimeout()
this.closeWebSocket()
},
methods: {
initSession() {
// 生成或获取 sessionId格式: userId_starId
const userRaw = uni.getStorageSync('user')
const starId = uni.getStorageSync('star_id')
// 解析 user可能是对象或 JSON 字符串)
let user = userRaw
if (typeof user === 'string') {
try {
user = JSON.parse(user)
} catch (e) {
console.error('[AI Chat] Failed to parse user:', e)
}
}
const uid = user?.uid || user?.["uid"]
if (uid && starId) {
this.sessionId = uid + '_' + starId
} else {
this.sessionId = 'temp_' + Date.now()
}
},
initWebSocket() {
const token = uni.getStorageSync('access_token')
if (!token) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
const socket = getAiChatSocket()
console.log('[AI Chat] initWebSocket called, socket already connected:', socket.isConnected)
// 设置消息回调 - 流式接收
socket.setOnMessageCallback((data) => {
console.log('[AI Chat] Message received:', data.type, data.session_id)
// 匹配 session_id 或空 session_id初始消息
if (data.session_id === this.sessionId || !data.session_id) {
this.handleStreamMessage(data)
} else {
console.log('[AI Chat] Session ID mismatch:', data.session_id, '!=', this.sessionId)
}
})
// 设置错误回调
socket.setOnErrorCallback((data) => {
console.error('AI Chat error:', data)
// 防止重复触发
if (this.isErrorProcessing) {
return
}
this.isErrorProcessing = true
this.isTyping = false
this.currentAssistantMessage = ''
this.aiMessage = '亲爱的你来辣 ~~'
// 重连失败时,将提示消息插入对话框显示
const errorMsg = data.message || data.error || '发生错误'
if (data.code === 'RECONNECT_FAILED') {
this.messages.push({
role: 'assistant',
content: errorMsg
})
this.scrollToBottom()
} else {
uni.showToast({ title: errorMsg, icon: 'none' })
}
// 延迟重置标志
setTimeout(() => {
this.isErrorProcessing = false
}, 1000)
})
// 设置断开回调 - 在对话框提示用户
const prevDisconnect = this._disconnectHandler
this._disconnectHandler = () => {
console.log('[AI Chat] WebSocket disconnected')
if (this.isTyping) {
if (this.currentAssistantMessage) {
this.messages.push({
role: 'assistant',
content: this.currentAssistantMessage
})
}
this.isTyping = false
this.currentAssistantMessage = ''
this.aiMessage = '亲爱的你来辣 ~~'
this.messages.push({
role: 'assistant',
content: '连接断开,正在尝试重新连接...'
})
this.scrollToBottom()
}
}
socket.off('disconnect', prevDisconnect)
socket.on('disconnect', this._disconnectHandler)
// 设置连接成功回调
socket.setOnConnectCallback(() => {
console.log('[AI Chat] Connected callback triggered, initialized:', this.initialized)
// 仅首次连接时初始化会话和加载历史,重连时跳过
// 避免重连后 "亲爱的你来辣 ~~" 重复出现在对话中
if (!this.initialized) {
this.initialized = true
this.loadHistory()
socket.initSession(this.sessionId)
}
})
// 每次重新连接前重置关闭标记
resetAiChatClosing()
// 如果 socket 单例已连接(热重载等场景),跳过 connect 直接初始化
if (socket.isConnected && socket.isAuthed) {
console.log('[AI Chat] Already connected, init directly')
socket.isClosing = false
if (!this.initialized) {
this.initialized = true
this.loadHistory()
socket.initSession(this.sessionId)
}
return
}
// 连接
socket.connect(token)
},
handleStreamMessage(data) {
// 超时后忽略后续到达的 token避免旧回复污染新对话
if (this.streamTimedOut) {
return
}
// 优先处理错误
if (data.error) {
console.log('[AI Chat] Error detected, setting isTyping=false')
this.clearStreamTimeout()
// 防止重复处理
if (this.isErrorProcessing) {
return
}
this.isErrorProcessing = true
this.isTyping = false
this.currentAssistantMessage = ''
uni.showToast({ title: data.content || data.error || '发生错误', icon: 'none' })
this.aiMessage = '亲爱的你来辣 ~~'
setTimeout(() => {
this.isErrorProcessing = false
}, 1000)
return
}
if (data.is_end) {
// 清除超时计时器
this.clearStreamTimeout()
// 结束标志
this.isTyping = false
// 将累积的完整回复保存到 messages
if (this.currentAssistantMessage) {
this.messages.push({
role: 'assistant',
content: this.currentAssistantMessage
})
this.currentAssistantMessage = ''
} else if (data.content) {
// init_session 欢迎消息:仅在对话为空时显示,避免与历史重复
if (this.messages.length === 0) {
this.messages.push({
role: 'assistant',
content: data.content
})
}
}
this.aiMessage = '亲爱的你来辣 ~~'
this.scrollToBottom()
} else {
// 累积消息内容
if (!this.currentAssistantMessage) {
this.currentAssistantMessage = ''
}
this.currentAssistantMessage += data.content
// 显示在气泡中
this.aiMessage = this.currentAssistantMessage
}
},
loadHistory() {
const token = uni.getStorageSync('access_token')
if (!token) return
const socket = getAiChatSocket()
// 设置历史回调 - 必须先设置再发送请求
socket.setOnHistoryCallback((data) => {
if ((data.session_id === this.sessionId || !data.session_id) && data.history) {
// 保留欢迎消息initSession 先返回时已被 push
const hadWelcome = this.messages.length === 1
&& this.messages[0].content === '亲爱的你来辣 ~~'
this.messages = data.history
if (hadWelcome && !this.messages.some(m => m.content === '亲爱的你来辣 ~~')) {
this.messages.unshift({ role: 'assistant', content: '亲爱的你来辣 ~~' })
}
}
this.$nextTick(() => {
this.scrollToBottom()
})
})
socket.getHistory(this.sessionId, 20)
},
handleSend() {
if (!this.inputText.trim() || this.isTyping) return
const message = this.inputText.trim()
const token = uni.getStorageSync('access_token')
if (!token) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
// 添加用户消息到列表
this.messages.push({
role: 'user',
content: message
})
this.inputText = ''
this.scrollToBottom()
// 发送消息
const socket = getAiChatSocket()
this.isTyping = true
this.currentAssistantMessage = ''
// 启动流式超时计时器
this.startStreamTimeout()
socket.sendMessage(message, this.sessionId)
},
startStreamTimeout() {
this.clearStreamTimeout()
this.streamTimedOut = false
this.streamTimeout = setTimeout(() => {
console.log('[AI Chat] Stream timeout, using fallback response')
this.streamTimedOut = true // 忽略超时后到达的 token
this.isTyping = false
if (this.currentAssistantMessage) {
this.messages.push({
role: 'assistant',
content: this.currentAssistantMessage
})
} else {
this.messages.push({
role: 'assistant',
content: '抱歉,服务响应有点慢,请稍后再试~'
})
}
this.currentAssistantMessage = ''
this.aiMessage = '亲爱的你来辣 ~~'
this.scrollToBottom()
}, this.streamTimeoutMs)
},
clearStreamTimeout() {
if (this.streamTimeout) {
clearTimeout(this.streamTimeout)
this.streamTimeout = null
}
},
scrollToBottom() {
this.scrollIntoViewId = ''
this.$nextTick(() => {
this.scrollIntoViewId = 'scroll-bottom-view'
})
},
closeWebSocket() {
console.log('[AI Chat] Closing WebSocket connection')
// 标记为关闭状态,阻止重连
const socket = getAiChatSocket()
socket.setClosing(true)
closeAiChatSocket()
},
handleClose() {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
} else {
uni.reLaunch({
url: '/pages/square/square'
})
}
},
handleDressup() {
uni.showToast({ title: '装扮功能开发中', icon: 'none' })
},
handleScene() {
uni.showToast({ title: '场景功能开发中', icon: 'none' })
},
handleHistory() {
uni.showToast({ title: '追星历程开发中', icon: 'none' })
}
}
}
</script>
<style scoped>
/* 整体容器 */
.ai-dazi-container {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
background: #f9c8d9;
}
/* 背景图 */
.bg-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
/* 关闭按钮 */
.close-btn {
position: absolute;
top: 80rpx;
left: 24rpx;
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.nav-back {
font-size: 48rpx;
font-weight: bold;
color: #fff;
}
/* 浮动装饰物 */
.decorations {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 60%;
z-index: 10;
pointer-events: none;
}
.deco {
position: absolute;
}
/* 左侧装饰(帽子/书本叠放) */
.deco-1 {
top: 100rpx;
left: 20rpx;
width: 220rpx;
height: 220rpx;
transform: rotate(-10deg);
filter: drop-shadow(0 8rpx 16rpx rgba(180, 100, 220, 0.3));
}
/* 右侧装饰(彩虹房子) */
.deco-2 {
top: 80rpx;
right: 30rpx;
width: 180rpx;
height: 180rpx;
transform: rotate(8deg);
filter: drop-shadow(0 8rpx 16rpx rgba(180, 100, 220, 0.3));
}
/* 功能按钮区域 */
.action-buttons {
position: absolute;
top: 280rpx;
left: 0;
width: 100%;
z-index: 20;
}
.action-btn {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
}
.btn-icon {
width: 144rpx;
height: 144rpx;
}
.btn-label {
width: 120rpx;
height: 60rpx;
margin-top: 8rpx;
}
.btn-label-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 128rpx;
height: 70rpx;
}
.btn-label-bg {
position: absolute;
width: 100%;
height: 100%;
}
.btn-label-text {
position: relative;
z-index: 1;
font-size: 16rpx;
color: #fff;
font-weight: 600;
line-height: 70rpx;
text-shadow: 0 1rpx 4rpx rgba(150, 50, 150, 0.5);
}
/* 装扮按钮 - 左侧中部 */
.dressup-btn {
left: 120rpx;
top: 0;
}
/* 场景按钮 - 右侧 */
.scene-btn {
right: 120rpx;
top: 100rpx;
}
/* 追星历程按钮 - 左侧下方 */
.history-btn {
left: 120rpx;
top: 296rpx;
}
/* 对话气泡区域 */
.dialog-area {
position: absolute;
top: 776rpx;
right: 72rpx;
z-index: 20;
display: flex;
justify-content: flex-end;
}
.dialog-bubble {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.bubble-bg {
width: auto;
height: 128rpx;
max-width: 500rpx;
}
.bubble-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 26rpx;
color: #fff;
font-weight: 600;
text-align: center;
letter-spacing: 1rpx;
line-height: 1.4;
white-space: nowrap;
padding: 0 40rpx;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.5);
}
/* 聊天消息区域 */
.chat-messages {
position: absolute;
bottom: 192rpx;
left: 0;
right: 0;
height: 1024rpx;
padding: 20rpx;
z-index: 15;
box-sizing: border-box;
}
.message-item {
display: flex;
margin-bottom: 20rpx;
}
.message-item.user {
justify-content: flex-end;
}
.message-item.assistant {
justify-content: flex-start;
}
.message-bubble {
max-width: 70%;
padding: 20rpx;
border-radius: 20rpx;
font-size: 28rpx;
line-height: 1.4;
}
.user-bubble {
background: linear-gradient(135deg, #ff9de2, #c97bff);
color: #fff;
border-bottom-right-radius: 8rpx;
}
.assistant-bubble {
background: rgba(255, 255, 255, 0.9);
color: #5a3060;
border-bottom-left-radius: 8rpx;
}
.message-text {
word-break: break-all;
}
.typing {
animation: blink 1s infinite;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
/* 角色区域 */
.character-area {
position: absolute;
bottom: 160rpx;
left: 50%;
transform: translateX(-50%);
z-index: 15;
display: flex;
align-items: center;
justify-content: center;
}
.character-placeholder {
width: 200rpx;
height: 200rpx;
display: flex;
align-items: center;
justify-content: center;
}
.character-emoji {
font-size: 120rpx;
filter: drop-shadow(0 8rpx 20rpx rgba(200, 100, 200, 0.4));
}
/* 底部输入栏 */
.bottom-bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
display: flex;
align-items: center;
padding: 48rpx 24rpx;
padding-bottom: calc(48rpx + env(safe-area-inset-bottom));
background: rgba(249, 200, 217, 0.5);
border-top: 4rpx solid #ff9de2;
border-radius: 50rpx 50rpx 0 0;
gap: 16rpx;
}
.input-wrapper {
flex: 1;
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.85);
border-radius: 50rpx;
padding: 10rpx 16rpx 10rpx 24rpx;
box-shadow: 0 4rpx 20rpx rgba(200, 100, 200, 0.2);
border: 2rpx solid rgba(255, 255, 255, 0.8);
}
.send-btn {
width: 100rpx;
height: 60rpx;
border-radius: 30rpx;
background: linear-gradient(135deg, #ff9de2, #c97bff);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.send-btn-disabled {
background: #ccc;
}
.send-icon {
color: #fff;
font-size: 28rpx;
}
.chat-input {
width: 100%;
height: 60rpx;
font-size: 32rpx;
color: #5a3060;
background: transparent;
line-height: 60rpx;
}
.input-placeholder {
color: #c9a0d0;
font-size: 32rpx;
}
.add-btn {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: linear-gradient(135deg, #ff9de2, #c97bff);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 16rpx rgba(200, 100, 200, 0.5);
flex-shrink: 0;
}
.add-icon {
color: #fff;
font-size: 48rpx;
font-weight: 300;
line-height: 1;
margin-top: -4rpx;
}
</style>