1.完成ai聊天页面新
This commit is contained in:
@@ -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);
|
||||
});
|
||||
},
|
||||
|
758
pages/aiChat/simple_chat.vue
Normal file
758
pages/aiChat/simple_chat.vue
Normal 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>
|
@@ -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'
|
||||
});
|
||||
},
|
||||
|
||||
|
Reference in New Issue
Block a user