topfans/frontend/pages/ai-dazi/index.vue
2026-05-28 12:00:19 +08:00

712 lines
15 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: 15000, // 15秒超时
isErrorProcessing: false // 防止错误重复触发
}
},
onLoad() {
this.initSession()
this.initWebSocket()
},
onReady() {
console.log('[AI Chat] onReady called')
},
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
uni.showToast({ title: data.message || data.error || '发生错误', icon: 'none' })
this.aiMessage = '亲爱的你来辣 ~~'
// 延迟重置标志
setTimeout(() => {
this.isErrorProcessing = false
}, 1000)
})
// 设置连接成功回调
socket.setOnConnectCallback(() => {
console.log('[AI Chat] Connected callback triggered')
socket.initSession(this.sessionId)
this.loadHistory()
})
// 每次重新连接前重置关闭标记
resetAiChatClosing()
// 连接
socket.connect(token)
},
handleStreamMessage(data) {
// 优先处理错误
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 等一次性消息
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) {
this.messages = data.history
}
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.streamTimeout = setTimeout(() => {
console.log('[AI Chat] Stream timeout, using fallback response')
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: 544rpx;
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;
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>