1.完成ai聊天页面新

This commit is contained in:
2025-08-14 11:42:34 +08:00
parent d3b5f50a2a
commit 114a30acb8
7 changed files with 881 additions and 42 deletions

View File

@@ -1,20 +1,18 @@
<template>
<view class="chat-container">
<!-- 状态栏占位 -->
<view class="status-bar-placeholder"></view>
<!-- 自定义导航栏 -->
<!-- <view class="status-bar-placeholder"></view>
<view class="custom-nav-bar">
<!-- 左侧历史记录图标触发抽屉 -->
<view class="nav-left" @click="toggleHistoryDrawer">
<image src="/static/history.svg" mode="aspectFit" class="nav-icon"></image>
</view>
<!-- 中间标题 -->
<view class="nav-title">智水AI辅导员</view>
</view>
</view> -->
<!-- 消息列表 -->
<scroll-view scroll-y class="message-list" :scroll-top="scrollTop" scroll-with-animation enable-passive="true"
<scroll-view scroll-y class="message-list" :scroll-top="scrollTop" scroll-with-animation="true"
show-scrollbar="false" enhanced="true" bounces="true"
@scroll="onScroll" @scrolltoupper="loadMoreHistory" upper-threshold="50">
<!-- 加载提示 -->
<view v-if="isLoadingHistory" class="loading-history">
@@ -86,17 +84,17 @@
<!-- 消息输入框 -->
<input v-model="inputMessage" placeholder="输入消息..." @confirm="sendMessage" confirm-type="send" />
<!-- 添加图片按钮 -->
<img src="/static/add.svg" class="add-icon" @click="selectImage" />
<!-- <img src="/static/add.svg" class="add-icon" @click="selectImage" /> -->
<!-- 发送消息按钮 -->
<img src="/static/send.svg" class="send-icon" @click="sendMessage" />
<button class="send-button" @click="sendMessage">发送</button>
</view>
<!-- 悬浮按钮固定在右下角 -->
<view class="ai-hover" @click="goHome">
<!-- <view class="ai-hover" @click="goHome">
<view class="ai-hover-content">
<text class="ai-hover-text">AI</text>
</view>
</view>
</view> -->
</view>
</template>
@@ -174,7 +172,7 @@
// 第二次尝试处理iOS等需要额外触发的设备
setTimeout(() => {
this.forceScrollToBottom(30); // 使用稍小的偏移量
this.forceScrollToBottom(); // 确保滚动到底部
// 第三次确保(针对动态内容加载的情况)
if (attempt < 2) {
@@ -210,6 +208,19 @@
});
},
/**
* 页面显示时触发
*/
onShow() {
// 页面显示时确保滚动到底部
this.$nextTick(() => {
setTimeout(() => {
console.log('onShow: 尝试滚动到底部');
this.forceScrollToBottom();
}, 200);
});
},
/* ---------- 方法 ---------- */
methods: {
/**
@@ -223,28 +234,53 @@
/**
* 强制滚动到底部
* @param {number} offset - 额外的偏移量,默认为50
* @param {number} offset - 额外的偏移量默认为0
*/
forceScrollToBottom(offset = 50) {
forceScrollToBottom(offset = 0) {
console.log('forceScrollToBottom: 开始执行滚动');
this.$nextTick(() => {
try {
const query = uni.createSelectorQuery().in(this);
query.select('.message-list').boundingClientRect(rect => {
if (rect) {
const targetScrollTop = rect.scrollHeight - rect.height - offset;
this.scrollTop = targetScrollTop;
// 双重确保滚动生效
setTimeout(() => {
this.scrollTop = targetScrollTop + 1;
query.select('.message-list').scrollOffset(res => {
if (res) {
console.log('forceScrollToBottom: 获取到scrollOffset', res);
console.log('forceScrollToBottom: 当前scrollTop', this.scrollTop);
// 使用scrollHeight如果没有则使用一个大值
const scrollHeight = res.scrollHeight || 99999;
const targetScrollTop = scrollHeight + 1000;
// 先重置scrollTop然后设置到底部
this.scrollTop = 0;
this.$nextTick(() => {
this.scrollTop = targetScrollTop;
console.log('forceScrollToBottom: 设置scrollTop为', targetScrollTop);
// 延迟再次确保滚动到底部
setTimeout(() => {
this.scrollTop = targetScrollTop;
}, 50);
}, 100);
const finalScrollTop = (res.scrollHeight || 99999) + 1000;
this.scrollTop = finalScrollTop;
console.log('forceScrollToBottom: 延迟设置scrollTop完成', finalScrollTop);
}, 100);
});
} else {
console.log('forceScrollToBottom: 未获取到scrollOffset');
// 直接使用备用方案
this.scrollTop = 0;
this.$nextTick(() => {
this.scrollTop = 99999;
console.log('forceScrollToBottom: 使用备用方案1');
});
}
}).exec();
} catch (error) {
console.error('滚动到底部失败:', error);
// 备用方案:直接设置一个很大的值
this.scrollTop = 0;
this.$nextTick(() => {
this.scrollTop = 99999;
console.log('forceScrollToBottom: 使用备用方案2');
});
}
});
},
@@ -620,6 +656,9 @@
});
this.inputMessage = '';
this.saveMessagesToLocal(); // 保存用户消息
// 立即滚动到底部显示用户消息
this.forceScrollToBottom();
// 添加AI消息占位
const aiIdx = this.messages.push({
@@ -703,7 +742,7 @@
}
// 最终完成后再次确保滚动到底部
this.forceScrollToTop();
this.forceScrollToBottom();
}
} catch (e) {
console.warn('JSON解析失败:', line, e);
@@ -741,13 +780,22 @@
this.$nextTick(() => {
setTimeout(() => {
const query = uni.createSelectorQuery().in(this);
query.select('.message-list').boundingClientRect(rect => {
if (rect) {
// 确保滚动到最底部
this.scrollTop = rect.scrollHeight;
}
}).exec();
try {
const query = uni.createSelectorQuery().in(this);
query.select('.message-list').scrollOffset(res => {
if (res && res.scrollHeight) {
// 使用大值确保滚动到最底部
this.scrollTop = res.scrollHeight + 1000;
} else {
// 备用方案
this.scrollTop = 99999;
}
}).exec();
} catch (error) {
console.error('滚动失败:', error);
// 备用方案
this.scrollTop = 99999;
}
}, isStreaming ? 50 : 100);
});
},

View File

@@ -0,0 +1,758 @@
<template>
<view class="chat-container">
<!-- 消息列表 -->
<scroll-view scroll-y class="message-list" :scroll-top="scrollTop" :scroll-with-animation="scrollWithAnimation"
:scroll-into-view="scrollIntoView" @scroll="onScroll">
<!-- 加载提示 -->
<view v-if="isLoadingHistory" class="loading-history">
<text>正在加载历史记录...</text>
</view>
<!-- 没有更多历史记录提示 -->
<view v-if="!hasMoreHistory && messages.length > 0" class="no-more-history">
<text>没有更多历史记录了</text>
</view>
<!-- 消息列表 -->
<view v-for="(item, index) in messages" :key="item.messageId || index" :id="'msg-' + item.messageId"
:class="['message-item', item.sender === 'user' ? 'user-message' : 'ai-message']">
<!-- 头像 -->
<image class="avatar" :src="item.avatar"></image>
<!-- 消息内容 -->
<view class="message-content">
<!-- AI消息支持Markdown渲染 -->
<view v-if="item.content && item.sender === 'ai'" class="markdown-content"
v-html="renderMarkdown(item.content)"></view>
<!-- 用户消息纯文本 -->
<text v-else-if="item.content">{{ item.content }}</text>
<!-- AI操作区域 -->
<view v-if="item.sender === 'ai'" class="ai-actions">
<text class="ai-text">回答由AI生成</text>
<view class="icon-group">
<img src="/static/good.svg"
:class="['btn-icon', { 'btn-icon-active': item.feedback && item.feedback.rating === 'like' }]"
@click="handleThumbUp(item.messageId, index)" />
<img src="/static/tread.svg"
:class="['btn-icon', { 'btn-icon-active': item.feedback && item.feedback.rating === 'dislike' }]"
@click="handleThumbDown(item.messageId, index)" />
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 输入框区域 -->
<view class="input-container">
<input v-model="inputMessage" placeholder="输入消息..." @confirm="sendMessage" confirm-type="send"
:disabled="sending" />
<button class="send-button" @click="sendMessage" :disabled="sending || !inputMessage.trim()">
{{ sending ? '发送中...' : '发送' }}
</button>
</view>
</view>
</template>
<script>
import { createChatStream } from '@/utils/ai_stream.js';
import { sendFeedback, getHistory } from '@/api/aiChat/ai_index.js';
import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';
// Markdown配置
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true
});
// DOMPurify安全配置
DOMPurify.addHook('afterSanitizeAttributes', node => {
if (node.tagName === 'A') node.setAttribute('rel', 'noopener noreferrer');
});
export default {
data() {
return {
// 基础数据
inputMessage: '',
messages: [],
scrollTop: 0,
sending: false,
// 滚动控制
scrollWithAnimation: true,
scrollIntoView: '',
lastScrollTop: 0,
loadThreshold: 200, // 距离顶部200px时开始加载
loadDebounceTimer: null, // 加载防抖定时器
// 用户信息
user: uni.getStorageSync('stuNo') || '',
userId: uni.getStorageSync('stuId') || '',
userName: uni.getStorageSync('stuName') || '',
// 对话相关
conversation_id: null,
currentCancel: null,
// 分页加载
isLoadingHistory: false,
hasMoreHistory: true,
earliestMessageId: null
};
},
onLoad() {
// 检查登录状态
if (!this.user) {
this.showToast('请先登录');
setTimeout(() => {
uni.navigateTo({ url: '/pages/login/index' });
}, 1500);
return;
}
// 获取对话ID
this.conversation_id = uni.getStorageSync('conversation_id') || null;
// 初始化聊天
this.initChat();
},
onUnload() {
// 清理防抖定时器
if (this.loadDebounceTimer) {
clearTimeout(this.loadDebounceTimer);
this.loadDebounceTimer = null;
}
},
methods: {
/**
* 初始化聊天 - 获取历史记录
*/
async initChat() {
try {
const res = await getHistory({
user: this.user,
conversationId: this.conversation_id || '',
limit: 10
});
if (res.code === 200 && res.data && Array.isArray(res.data.data)) {
const newMessages = [];
// 处理历史消息
res.data.data.forEach(msg => {
// 用户消息
if (msg.query) {
newMessages.push({
sender: 'user',
avatar: '/static/yonghu.png',
content: msg.query,
messageId: msg.id,
conversationId: msg.conversation_id,
created_at: msg.created_at
});
}
// AI消息
if (msg.answer) {
newMessages.push({
sender: 'ai',
avatar: '/static/AI.png',
content: msg.answer,
messageId: 'ai-' + msg.id,
conversationId: msg.conversation_id,
created_at: msg.created_at,
feedback: msg.feedback || null // 添加反馈状态
});
}
});
// 按时间排序(从旧到新)
newMessages.sort((a, b) => {
return a.created_at - b.created_at;
});
this.messages = newMessages;
if (newMessages.length > 0) {
this.conversation_id = newMessages[0].conversationId;
uni.setStorageSync('conversation_id', this.conversation_id);
// 设置最早的用户消息ID用于分页
const userMessages = newMessages.filter(msg => msg.sender === 'user');
if (userMessages.length > 0) {
this.earliestMessageId = userMessages[0].messageId;
} else {
// 如果没有用户消息使用第一个消息的原始ID
this.earliestMessageId = newMessages[0].messageId.replace('ai-', '');
}
}
this.hasMoreHistory = res.data.has_more || false;
} else {
// 没有历史记录,显示欢迎消息
this.messages = [{
sender: 'ai',
avatar: '/static/AI.png',
content: '你好我是智水AI辅导员有什么可以帮助你的吗',
messageId: 'welcome-' + Date.now()
}];
this.hasMoreHistory = false;
}
// 滚动到底部
this.$nextTick(() => {
setTimeout(() => {
this.scrollToBottom();
// 初始化滚动位置记录
this.lastScrollTop = 99999; // 设置为底部位置
}, 200);
});
} catch (error) {
console.error('初始化聊天失败:', error);
this.showToast('加载历史记录失败');
// 显示欢迎消息
this.messages = [{
sender: 'ai',
avatar: '/static/AI.png',
content: '你好我是智水AI辅导员有什么可以帮助你的吗',
messageId: 'welcome-' + Date.now()
}];
}
},
/**
* 加载更多历史记录
*/
async loadMoreHistory() {
if (this.isLoadingHistory || !this.hasMoreHistory || !this.conversation_id || !this.earliestMessageId) {
return;
}
this.isLoadingHistory = true;
try {
// 记录当前第一个可见消息的ID作为锚点
const anchorMessageId = this.messages.length > 0 ? this.messages[0].messageId : null;
const res = await getHistory({
user: this.user,
conversationId: this.conversation_id,
limit: 10,
beforeId: this.earliestMessageId
});
if (res.code === 200 && res.data && Array.isArray(res.data.data) && res.data.data.length > 0) {
const newMessages = [];
// 处理新获取的消息
res.data.data.forEach(msg => {
// 用户消息
if (msg.query) {
newMessages.push({
sender: 'user',
avatar: '/static/yonghu.png',
content: msg.query,
messageId: msg.id,
conversationId: msg.conversation_id,
created_at: msg.created_at
});
}
// AI消息
if (msg.answer) {
newMessages.push({
sender: 'ai',
avatar: '/static/AI.png',
content: msg.answer,
messageId: 'ai-' + msg.id,
conversationId: msg.conversation_id,
created_at: msg.created_at,
feedback: msg.feedback || null // 添加反馈状态
});
}
});
// 按时间排序(从旧到新)
newMessages.sort((a, b) => {
return a.created_at - b.created_at;
});
// 禁用滚动动画,避免闪烁
this.scrollWithAnimation = false;
// 插入到现有消息前面
this.messages = [...newMessages, ...this.messages];
// 更新最早消息ID为新获取消息中最早的那个用于下次分页
if (newMessages.length > 0) {
// 找到最早的用户消息ID不带'ai-'前缀的)
const userMessages = newMessages.filter(msg => msg.sender === 'user');
if (userMessages.length > 0) {
this.earliestMessageId = userMessages[0].messageId;
} else {
// 如果没有用户消息使用第一个消息的原始ID
this.earliestMessageId = newMessages[0].messageId.replace('ai-', '');
}
}
this.hasMoreHistory = res.data.has_more || false;
// 使用scroll-into-view定位到锚点消息实现无闪烁固定位置
if (anchorMessageId) {
this.$nextTick(() => {
// 直接滚动到锚点消息位置
this.scrollIntoView = 'msg-' + anchorMessageId;
// 短暂延迟后重新启用滚动动画
setTimeout(() => {
this.scrollWithAnimation = true;
this.scrollIntoView = ''; // 清空scroll-into-view
}, 100);
});
}
} else {
this.hasMoreHistory = false;
}
} catch (error) {
console.error('加载历史记录失败:', error);
this.showToast('加载历史记录失败');
} finally {
this.isLoadingHistory = false;
}
},
/**
* 发送消息
*/
async sendMessage() {
const message = this.inputMessage.trim();
if (!message || this.sending) return;
this.sending = true;
// 添加用户消息
this.messages.push({
sender: 'user',
avatar: '/static/yonghu.png',
content: message,
messageId: Date.now().toString()
});
this.inputMessage = '';
this.scrollToBottom();
// 添加AI消息占位
const aiIndex = this.messages.push({
sender: 'ai',
avatar: '/static/AI.png',
content: '正在思考...',
messageId: 'pending-' + Date.now().toString(),
feedback: null // 初始化反馈状态
}) - 1;
this.scrollToBottom();
// 取消之前的请求
if (this.currentCancel) {
this.currentCancel('新消息发送,终止旧连接');
}
try {
const { stream, cancel } = createChatStream({
conversationId: this.conversation_id,
prompt: message,
user: this.user,
userId: this.userId,
userName: this.userName
});
this.currentCancel = cancel;
const { reader, decoder } = await stream;
let buffer = '';
// 流式处理响应
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
let jsonStr = line.replace(/^data:/, '').trim();
const data = JSON.parse(jsonStr);
// 更新消息内容
if (data.event === 'message' && data.answer) {
const currentContent = this.messages[aiIndex].content;
const newContent = (currentContent === '正在思考...' ? '' : currentContent) + data.answer;
this.$set(this.messages[aiIndex], 'content', newContent);
// 流式响应中滚动到底部
this.scrollToBottom();
}
// 处理结束消息
if (data.event === 'message_end') {
if (data.conversation_id) {
this.conversation_id = data.conversation_id;
uni.setStorageSync('conversation_id', this.conversation_id);
}
if (data.message_id) {
this.$set(this.messages[aiIndex], 'messageId', data.message_id);
// 确保新消息有feedback字段
if (!this.messages[aiIndex].feedback) {
this.$set(this.messages[aiIndex], 'feedback', { rating: null });
}
}
// 最终滚动到底部
this.scrollToBottom();
}
} catch (e) {
console.warn('JSON解析失败:', line, e);
}
}
}
} catch (error) {
console.error('发送消息失败:', error);
this.showToast('发送失败,请重试');
// 更新AI消息为错误提示
this.$set(this.messages[aiIndex], 'content', '抱歉,发送失败了,请重试。');
} finally {
this.sending = false;
this.currentCancel = null;
}
},
/**
* 滚动到底部
*/
scrollToBottom() {
this.$nextTick(() => {
const query = uni.createSelectorQuery().in(this);
query.select('.message-list').scrollOffset(res => {
if (res) {
const scrollHeight = res.scrollHeight || 99999;
this.scrollTop = scrollHeight + 1000;
}
}).exec();
});
},
/**
* 滚动事件监听
*/
onScroll(e) {
const scrollTop = e.detail.scrollTop;
// 检查是否需要加载历史记录
// 当滚动到距离顶部阈值范围内且向上滚动时触发加载
if (scrollTop <= this.loadThreshold &&
scrollTop < this.lastScrollTop &&
this.hasMoreHistory &&
!this.isLoadingHistory) {
// 清除之前的防抖定时器
if (this.loadDebounceTimer) {
clearTimeout(this.loadDebounceTimer);
}
// 设置防抖延迟加载
this.loadDebounceTimer = setTimeout(() => {
if (this.hasMoreHistory && !this.isLoadingHistory) {
this.loadMoreHistory();
}
}, 300); // 300ms防抖延迟
}
// 更新上次滚动位置
this.lastScrollTop = scrollTop;
},
/**
* 渲染Markdown内容
*/
renderMarkdown(text) {
return DOMPurify.sanitize(md.render(text || ''));
},
/**
* 点赞
*/
handleThumbUp(messageId, index) {
if (!messageId) {
this.showToast('消息ID不存在');
return;
}
// 去除AI消息ID中的'ai-'前缀
const actualMessageId = messageId.replace('ai-', '');
sendFeedback({
messageId: actualMessageId,
action: 1,
user: this.user
}).then(res => {
if (res.code === 200) {
this.showToast('点赞成功', 'success');
// 更新本地消息的反馈状态
this.$set(this.messages[index], 'feedback', { rating: 'like' });
} else {
this.showToast(res.msg || '点赞失败');
}
}).catch(err => {
console.error('点赞失败:', err);
this.showToast('点赞失败');
});
},
/**
* 点踩
*/
handleThumbDown(messageId, index) {
if (!messageId) {
this.showToast('消息ID不存在');
return;
}
// 去除AI消息ID中的'ai-'前缀
const actualMessageId = messageId.replace('ai-', '');
sendFeedback({
messageId: actualMessageId,
action: 0,
user: this.user
}).then(res => {
if (res.code === 200) {
this.showToast('已反馈', 'success');
// 更新本地消息的反馈状态
this.$set(this.messages[index], 'feedback', { rating: 'dislike' });
} else {
this.showToast(res.msg || '反馈失败');
}
}).catch(err => {
console.error('反馈失败:', err);
this.showToast('反馈失败');
});
},
/**
* 显示提示
*/
showToast(title, icon = 'none') {
uni.showToast({ title, icon });
}
}
};
</script>
<style scoped>
/* 整体容器 */
.chat-container {
display: flex;
flex-direction: column;
/* 原来是 height: 100vh; 改为: */
height: calc(100vh - var(--window-top) - var(--window-bottom));
width: 100%;
background-color: #f5f5f5;
overflow: hidden;
}
/* 消息列表 */
.message-list {
flex: 1;
/* height: 300rpx; <- 删除这行 */
min-height: 0;
/* 关键:允许在 flex 布局里被压缩从而出现滚动 */
padding: 20rpx 0;
background-color: #f5f5f5;
box-sizing: border-box;
}
/* 加载提示 */
.loading-history {
text-align: center;
padding: 20px;
color: #666;
font-size: 14px;
}
.no-more-history {
text-align: center;
padding: 20px;
color: #999;
font-size: 12px;
}
/* 消息项 */
.message-item {
display: flex;
align-items: flex-start;
margin-bottom: 16px;
max-width: 100%;
}
.user-message {
flex-direction: row-reverse;
margin-right: 20px;
}
.ai-message {
flex-direction: row;
margin-left: 20px;
}
/* 头像 */
.avatar {
width: 40px;
height: 40px;
border-radius: 8px;
margin: 0 8px;
}
/* 消息内容 */
.message-content {
max-width: 70%;
padding: 10px 12px;
border-radius: 10px;
word-wrap: break-word;
line-height: 1.5;
}
.user-message .message-content {
background-color: #e1f5fe;
}
.ai-message .message-content {
background-color: #fff;
border: 1px solid #eee;
}
/* AI操作区域 */
.ai-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
font-size: 12px;
color: #666;
}
.ai-text {
color: #999;
}
.icon-group {
display: flex;
gap: 12px;
}
.btn-icon {
width: 16px;
height: 16px;
cursor: pointer;
transition: all 0.3s ease;
opacity: 0.6;
}
.btn-icon-active {
opacity: 1;
filter: brightness(1.2) saturate(1.5);
transform: scale(1.1);
}
/* 输入框区域 */
.input-container {
display: flex;
align-items: center;
padding: 8px 16px;
background-color: #fff;
border-top: 1px solid #eee;
box-sizing: border-box;
padding-bottom: calc(env(safe-area-inset-bottom) + 10px);
}
.input-container input {
flex: 1;
height: 40px;
padding: 0 16px;
background-color: #f5f5f5;
border: none;
border-radius: 20px;
font-size: 14px;
margin-right: 10px;
outline: none;
}
.send-button {
padding: 8px 16px;
background-color: #007aff;
color: white;
border: none;
border-radius: 20px;
font-size: 14px;
cursor: pointer;
}
.send-button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
/* Markdown内容样式 */
.markdown-content {
line-height: 1.6;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3 {
margin: 10px 0 5px 0;
font-weight: bold;
}
.markdown-content p {
margin: 5px 0;
}
.markdown-content code {
background-color: #f0f0f0;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
.markdown-content pre {
background-color: #f0f0f0;
padding: 10px;
border-radius: 5px;
overflow-x: auto;
margin: 10px 0;
}
.markdown-content ul,
.markdown-content ol {
padding-left: 20px;
margin: 5px 0;
}
.markdown-content li {
margin: 2px 0;
}
</style>

View File

@@ -33,7 +33,6 @@
</view>
</view>
</view>
</workbench>
<!-- AI 悬浮按钮 -->
<view class="ai-hover" @click="toAI">
@@ -288,10 +287,10 @@
getImgUrl(name) {
return require('../../static/images/workbench/' + name + '.png');
},
//跳转到AI
toAI() {
uni.navigateTo({
url: '/pages/aiChat/ai_index'
url: '/pages/aiChat/simple_chat'
});
},