AI弹窗
38
api/aiChat/ai_index.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// src/api/index.js
|
||||
// import request from '@/utils/ai_request.js'
|
||||
import request from "../../utils/ai_request";
|
||||
|
||||
// 获取历史
|
||||
export const getHistory = ({
|
||||
conversationId,
|
||||
user,
|
||||
limit = 20
|
||||
}) => {
|
||||
return request({
|
||||
url: '/aitutor/aichat/getMessagesToUser',
|
||||
method: 'get',
|
||||
params: {
|
||||
conversationId,
|
||||
user,
|
||||
limit
|
||||
}
|
||||
// headers: {
|
||||
// Authorization: 'Bearer eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6IjBmMTY3NmY2LTgwOGMtNGUwMC04NDJjLWIwNmY1ZTM5NzJlNCJ9.VVc6OwQ-Xn9pxzYbPhlCpvDp6TwESS00gJi9IXUEIbFw4RFACZDmYCYjQ7voTM4fppy9SAMJCWT-L7Uy-K1eqw'
|
||||
// }
|
||||
});
|
||||
};
|
||||
|
||||
// 点赞/点踩 action: 1 点赞 0 点踩
|
||||
export const sendFeedback = ({
|
||||
messageId,
|
||||
action
|
||||
}) => {
|
||||
return request({
|
||||
url: '/api/chat/feedback',
|
||||
method: 'post',
|
||||
data: {
|
||||
messageId,
|
||||
action
|
||||
}
|
||||
});
|
||||
};
|
||||
411
components/aiChat/HistoryDrawer.vue
Normal file
@@ -0,0 +1,411 @@
|
||||
<!-- HistoryDrawer.vue -->
|
||||
<template>
|
||||
<!-- 抽屉容器,visible控制显示 -->
|
||||
<view v-if="visible" class="drawer-container" @touchmove.stop.prevent>
|
||||
<!-- 遮罩层,点击关闭 -->
|
||||
<view class="drawer-mask" @click="closeDrawer"></view>
|
||||
|
||||
<!-- 抽屉内容区域 -->
|
||||
<view class="drawer-content">
|
||||
<!-- 标题区域 -->
|
||||
<view class="drawer-header">
|
||||
<text class="title">历史记录</text>
|
||||
<image src="/static/close.svg" mode="aspectFit" class="close-icon" @click="closeDrawer" />
|
||||
</view>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<view class="search-bar">
|
||||
<image src="/static/search.svg" mode="aspectFit" class="search-icon" />
|
||||
<input v-model="searchKeyword" placeholder="搜索聊天记录..." class="search-input" @input="handleSearch" />
|
||||
<image v-if="searchKeyword" src="/static/clear.svg" class="clear-icon" @click="clearSearch" />
|
||||
</view>
|
||||
|
||||
<!-- 历史列表区域 -->
|
||||
<scroll-view scroll-y class="history-list">
|
||||
<!-- 分组渲染历史记录 -->
|
||||
<block v-for="(group, gIndex) in filteredRecords" :key="gIndex">
|
||||
<view class="group-title">{{ group.title }}</view>
|
||||
<view v-for="item in group.list" :key="item.id" class="history-item" @click="onItemClick(item)">
|
||||
<!-- 日期时间显示 -->
|
||||
<view class="datetime">
|
||||
<text class="date">{{ item.date }}</text>
|
||||
<text class="time">{{ item.time }}</text>
|
||||
</view>
|
||||
<!-- 消息内容 -->
|
||||
<view class="record">
|
||||
<text class="user-msg" v-html="highlightKeyword(item.content)"></text>
|
||||
<text class="ai-msg" v-html="highlightKeyword(item.reply)"></text>
|
||||
</view>
|
||||
<view class="divider"></view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 空状态提示 -->
|
||||
<view v-if="filteredRecords.length === 0" class="empty-tip">
|
||||
<text>{{ searchKeyword ? '没有找到匹配的记录' : '暂无聊天记录' }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
getHistory
|
||||
} from '../../api/aiChat/ai_index.js'; // 历史记录API
|
||||
|
||||
export default {
|
||||
name: 'HistoryDrawer',
|
||||
props: {
|
||||
visible: Boolean // 控制抽屉显示
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
historyRecords: [], // 原始历史记录
|
||||
filteredRecords: [], // 过滤后的历史记录
|
||||
searchKeyword: '' // 搜索关键词
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
// 监听visible变化,显示时加载记录
|
||||
visible(newVal) {
|
||||
if (newVal) this.loadHistoryRecords();
|
||||
else this.clearSearch();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 关闭抽屉
|
||||
closeDrawer() {
|
||||
this.$emit('close');
|
||||
},
|
||||
|
||||
/* ========== 1. 读本地缓存兜底 ========== */
|
||||
renderLocal(list) {
|
||||
// 将本地缓存的消息按日期分组
|
||||
const groupMap = {};
|
||||
list.forEach((m, idx) => {
|
||||
const date = new Date();
|
||||
const formatted = {
|
||||
date: this.formatDate(date),
|
||||
time: this.formatTime(date),
|
||||
content: m.sender === 'user' ? m.content : '',
|
||||
reply: m.sender === 'ai' ? m.content : '',
|
||||
id: idx
|
||||
};
|
||||
const title = this.getGroupTitle(date);
|
||||
(groupMap[title] ||= []).push(formatted);
|
||||
});
|
||||
|
||||
// 转换为数组形式
|
||||
this.historyRecords = Object.entries(groupMap).map(([t, arr]) => ({
|
||||
title: t,
|
||||
list: arr
|
||||
}));
|
||||
this.filteredRecords = [...this.historyRecords];
|
||||
},
|
||||
|
||||
/* ========== 2. 加载接口或本地缓存 ========== */
|
||||
async loadHistoryRecords() {
|
||||
try {
|
||||
// 1. 先读本地缓存,立即显示(避免空白)
|
||||
const local = uni.getStorageSync('chatHistory') || [];
|
||||
if (local.length) this.renderLocal(local);
|
||||
|
||||
// 2. 再调接口,成功后覆盖本地
|
||||
const res = await getHistory({
|
||||
conversationId: '5665af64-22b4-4a59-b15f-2126eb056302',
|
||||
user: '2023429112',
|
||||
limit: 50
|
||||
});
|
||||
console.log('原始返回结构', res);
|
||||
|
||||
const list = res?.data?.data || [];
|
||||
if (!list.length) return; // 接口返回空数组也保留本地
|
||||
|
||||
// 处理API返回的数据
|
||||
const groupMap = {};
|
||||
list.forEach(item => {
|
||||
const ts = item.created_at ?? item.create_time ?? item.timestamp ?? 0;
|
||||
const date = String(ts).length === 13 ? new Date(ts) : new Date(ts * 1000);
|
||||
|
||||
const formatted = {
|
||||
date: this.formatDate(date),
|
||||
time: this.formatTime(date),
|
||||
content: item.query || item.question || item.content || '未知内容',
|
||||
reply: item.answer || item.reply || '暂无回复',
|
||||
id: item.id || Math.random().toString(36).slice(2)
|
||||
};
|
||||
|
||||
const title = this.getGroupTitle(date);
|
||||
(groupMap[title] ||= []).push(formatted);
|
||||
});
|
||||
|
||||
// 按时间排序并更新数据
|
||||
this.historyRecords = Object.entries(groupMap).map(([title, arr]) => ({
|
||||
title,
|
||||
list: arr.sort((a, b) => b.timestamp - a.timestamp)
|
||||
}));
|
||||
this.filteredRecords = [...this.historyRecords];
|
||||
} catch (e) {
|
||||
console.error('历史接口 401,已使用本地缓存兜底', e);
|
||||
// 接口失败时,保留本地缓存(不覆盖)
|
||||
}
|
||||
},
|
||||
|
||||
// 处理搜索输入
|
||||
handleSearch() {
|
||||
const kw = this.searchKeyword.trim().toLowerCase();
|
||||
if (!kw) return (this.filteredRecords = [...this.historyRecords]);
|
||||
|
||||
// 过滤历史记录
|
||||
this.filteredRecords = this.historyRecords
|
||||
.map(g => ({
|
||||
...g,
|
||||
list: g.list.filter(i =>
|
||||
i.content.toLowerCase().includes(kw) ||
|
||||
i.reply.toLowerCase().includes(kw)
|
||||
)
|
||||
}))
|
||||
.filter(g => g.list.length);
|
||||
},
|
||||
|
||||
// 清除搜索
|
||||
clearSearch() {
|
||||
this.searchKeyword = '';
|
||||
this.filteredRecords = [...this.historyRecords];
|
||||
},
|
||||
|
||||
// 高亮搜索关键词
|
||||
highlightKeyword(text) {
|
||||
if (!this.searchKeyword || !text) return text;
|
||||
const kw = this.searchKeyword.trim();
|
||||
return text.replace(
|
||||
new RegExp(kw, 'gi'),
|
||||
`<span class="highlight">${kw}</span>`
|
||||
);
|
||||
},
|
||||
|
||||
// 获取分组标题(今天/昨天/7天内等)
|
||||
getGroupTitle(date) {
|
||||
const now = new Date();
|
||||
const today = new Date(now.setHours(0, 0, 0, 0));
|
||||
const yesterday = new Date(today).setDate(today.getDate() - 1);
|
||||
const week = new Date(today).setDate(today.getDate() - 7);
|
||||
const month = new Date(today).setDate(today.getDate() - 30);
|
||||
|
||||
if (date >= today) return '今天';
|
||||
if (date >= yesterday) return '昨天';
|
||||
if (date >= week) return '7天内';
|
||||
if (date >= month) return '30天内';
|
||||
return '更早';
|
||||
},
|
||||
|
||||
// 格式化日期为YYYY-MM-DD
|
||||
formatDate(date) {
|
||||
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date
|
||||
.getDate()
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
},
|
||||
|
||||
// 格式化时间为HH:MM
|
||||
formatTime(date) {
|
||||
return `${date.getHours().toString().padStart(2, '0')}:${date
|
||||
.getMinutes()
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
},
|
||||
|
||||
// 点击历史记录项
|
||||
onItemClick(item) {
|
||||
// 移除HTML标签后触发事件
|
||||
this.$emit('item-click', {
|
||||
...item,
|
||||
content: item.content.replace(/<[^>]+>/g, ''),
|
||||
reply: item.reply.replace(/<[^>]+>/g, '')
|
||||
});
|
||||
this.closeDrawer();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 抽屉容器样式 */
|
||||
.drawer-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
/* 遮罩层样式 */
|
||||
.drawer-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* 抽屉内容区域样式 */
|
||||
.drawer-content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 66.67%;
|
||||
height: 100vh;
|
||||
background-color: #fff;
|
||||
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 头部样式 */
|
||||
.drawer-header {
|
||||
padding: 15px 15px 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #eee;
|
||||
height: 50px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* 搜索栏样式 */
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 15px;
|
||||
background-color: #f5f5f5;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 8px;
|
||||
tint-color: #999;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
background-color: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 0 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 8px;
|
||||
tint-color: #999;
|
||||
}
|
||||
|
||||
/* 历史列表样式 */
|
||||
.history-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 分组标题样式 */
|
||||
.group-title {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
padding: 12px 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 历史记录项样式 */
|
||||
.history-item {
|
||||
padding: 15px 20px;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
/* 日期时间样式 */
|
||||
.datetime {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 消息内容样式 */
|
||||
.record {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.user-msg {
|
||||
display: block;
|
||||
color: #333;
|
||||
margin-bottom: 6px;
|
||||
padding: 6px 10px;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ai-msg {
|
||||
display: block;
|
||||
color: #333;
|
||||
padding: 6px 10px;
|
||||
background-color: #eef7ff;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* 关键词高亮样式 */
|
||||
.highlight {
|
||||
color: #ff4d4f;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 分隔线样式 */
|
||||
.divider {
|
||||
height: 8px;
|
||||
background-color: #f9f9f9;
|
||||
margin-top: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 空状态提示样式 */
|
||||
.empty-tip {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
/* 修复输入框占位符样式 */
|
||||
input::-webkit-input-placeholder {
|
||||
color: #ccc;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@ module.exports = {
|
||||
//baseUrl: 'http://zhxg.gxsdxy.cn/prod_api',
|
||||
// baseUrl: 'http://172.16.96.111:8085',
|
||||
// baseUrl: 'http://192.168.211.22:8085',
|
||||
baseUrl: 'http://localhost:8085',
|
||||
baseUrl: 'http://localhost:8088',
|
||||
// 应用信息
|
||||
appInfo: {
|
||||
// 应用名称
|
||||
@@ -28,7 +28,7 @@ module.exports = {
|
||||
/**
|
||||
* 开启cas
|
||||
*/
|
||||
casEnable: true,
|
||||
casEnable: false,
|
||||
|
||||
/**
|
||||
* 单点登录url
|
||||
|
||||
11
package.json
@@ -12,7 +12,18 @@
|
||||
"@types/uni-app": "^1.4.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app-plus": "^2.0.2-4070520250711001",
|
||||
"@dcloudio/uni-cli-i18n": "^2.0.2-4070520250711001",
|
||||
"@dcloudio/uni-cli-shared": "^2.0.2-4070520250711001",
|
||||
"@dcloudio/uni-helper-json": "^1.0.13",
|
||||
"@dcloudio/uni-migration": "^2.0.2-4070520250711001",
|
||||
"@dcloudio/uni-stat": "^2.0.2-4070520250711001",
|
||||
"@dcloudio/vue-cli-plugin-uni": "^2.0.2-4070520250711001",
|
||||
"@vue/cli-service": "^5.0.8",
|
||||
"@wecom/jssdk": "^2.3.1",
|
||||
"axios": "^1.11.0",
|
||||
"dompurify": "^3.2.6",
|
||||
"markdown-it": "^14.1.0",
|
||||
"weixin-js-sdk": "^1.6.5"
|
||||
}
|
||||
}
|
||||
|
||||
13
pages.json
@@ -1148,14 +1148,19 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"path" : "pages/sub/StudoEdit",
|
||||
"style" :
|
||||
{
|
||||
"navigationBarTitleText" : "离校留校申请",
|
||||
"path": "pages/sub/StudoEdit",
|
||||
"style": {
|
||||
"navigationBarTitleText": "离校留校申请",
|
||||
"enablePullDownRefresh": false,
|
||||
"navigationBarBackgroundColor": "#1890FF",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/aiChat/ai_index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI辅导员"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- Login.vue -->
|
||||
<template>
|
||||
<view class="container">
|
||||
<view class="logo">
|
||||
@@ -42,19 +43,19 @@
|
||||
|
||||
methods: {
|
||||
login() {
|
||||
let sdata= {
|
||||
let sdata = {
|
||||
username: this.username,
|
||||
password: this.password
|
||||
};
|
||||
doLogin(sdata).then(res=>{
|
||||
doLogin(sdata).then(res => {
|
||||
if (res.code == 200) {
|
||||
uni.setStorageSync('token', res.token);
|
||||
uni.showToast({
|
||||
title: "登录成功",
|
||||
icon: "success"
|
||||
});
|
||||
getInfo().then(res=>{
|
||||
uni.setStorageSync("roles",res.roles[0])
|
||||
getInfo().then(res => {
|
||||
uni.setStorageSync("roles", res.roles[0])
|
||||
})
|
||||
uni.switchTab({
|
||||
url: "/pages/index/index"
|
||||
@@ -67,7 +68,7 @@
|
||||
icon: "error"
|
||||
|
||||
})
|
||||
} else{
|
||||
} else {
|
||||
console.log('11');
|
||||
}
|
||||
})
|
||||
@@ -77,7 +78,7 @@
|
||||
togglePassword() {
|
||||
this.showPassword = !this.showPassword;
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
457
pages/aiChat/ai_index.vue
Normal file
@@ -0,0 +1,457 @@
|
||||
<!-- pages/aiChat/ai_index -->
|
||||
<template>
|
||||
<view class="chat-container">
|
||||
<!-- 状态保持:当AI聊天可见时才显示聊天内容 -->
|
||||
|
||||
<!-- 状态栏占位 -->
|
||||
<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 class="nav-right" @click="newChat">
|
||||
<image src="/static/newChat.svg" mode="aspectFit" class="nav-icon"></image>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<scroll-view scroll-y class="message-list" :scroll-top="scrollTop" scroll-with-animation>
|
||||
<block v-for="(item, index) in messages" :key="index">
|
||||
<view :class="['message-item', item.sender === 'user' ? 'user-message' : 'ai-message']">
|
||||
<!-- 用户/AI头像 -->
|
||||
<image class="avatar" :src="item.avatar"></image>
|
||||
<view class="message-content">
|
||||
<!-- 文字内容 -->
|
||||
<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>
|
||||
<!-- 图片内容 -->
|
||||
<image v-if="item.image" :src="item.image" class="sent-image"></image>
|
||||
<!-- AI 特有内容 -->
|
||||
<view v-if="item.sender === 'ai'" class="ai-hint">
|
||||
<!-- 引用来源部分 -->
|
||||
<view v-if="item.retrieverResources && item.retrieverResources.length"
|
||||
class="reference-section">
|
||||
<view class="reference-section">
|
||||
<text class="reference-title">引用来源:</text>
|
||||
<!-- 遍历每个引用资源 -->
|
||||
<view v-for="(ref, idx) in item.retrieverResources" :key="idx"
|
||||
class="reference-item-wrapper">
|
||||
<!-- 可点击的文档名 -->
|
||||
<text class="doc-name-link" @click="toggleSingleReference(index, idx)">
|
||||
{{ ref.document_name }}
|
||||
</text>
|
||||
|
||||
<!-- 展开的详情(仅当前项) -->
|
||||
<view v-if="showSingleReference[index] && showSingleReference[index][idx]"
|
||||
class="reference-details-item">
|
||||
<text class="reference-meta">{{ ref.name }}({{ ref.document_name }})</text>
|
||||
<text class="reference-content" v-if="ref.content">{{ ref.content }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- AI操作区域(点赞/点踩) -->
|
||||
<view class="ai-actions">
|
||||
<text class="ai-text">回答由AI生成</text>
|
||||
<view class="icon-group">
|
||||
<img src="/static/good.svg" class="btn-icon"
|
||||
@click="handleThumbUpClick(item.messageId)" />
|
||||
<img src="/static/tread.svg" class="btn-icon"
|
||||
@click="handleThumbDownClick(item.messageId)" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 输入框和发送按钮 -->
|
||||
<view class="input-container">
|
||||
<!-- 历史记录抽屉组件 -->
|
||||
<HistoryDrawer :visible="showHistoryDrawer" @close="toggleHistoryDrawer" @item-click="onHistoryItemClick" />
|
||||
|
||||
<!-- 消息输入框 -->
|
||||
<input v-model="inputMessage" placeholder="输入消息..." @confirm="sendMessage" confirm-type="send" />
|
||||
<!-- 添加图片按钮 -->
|
||||
<img src="/static/add.svg" class="add-icon" @click="selectImage" />
|
||||
<!-- 发送消息按钮 -->
|
||||
<img src="/static/send.svg" class="send-icon" @click="sendMessage" />
|
||||
</view>
|
||||
|
||||
<!-- 悬浮按钮:固定在右下角 -->
|
||||
<view class="ai-hover" @click="goHome">
|
||||
<view class="ai-hover-content">
|
||||
<text class="ai-hover-text">AI</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/* ========== 依赖 ========== */
|
||||
import HistoryDrawer from '@/components/aiChat/HistoryDrawer.vue'; // 历史记录抽屉组件
|
||||
import {
|
||||
createChatStream
|
||||
} from '@/utils/ai_stream.js'; // 流式聊天API
|
||||
import MarkdownIt from 'markdown-it'; // Markdown解析器
|
||||
import DOMPurify from 'dompurify'; // HTML净化器
|
||||
|
||||
/* DOMPurify 白名单加固 */
|
||||
DOMPurify.addHook('afterSanitizeAttributes', node => {
|
||||
if (node.tagName === 'A') node.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
// 初始化Markdown解析器
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true
|
||||
});
|
||||
|
||||
/* ========== 配置 ========== */
|
||||
const BASE_URL = (function() {
|
||||
// #ifdef H5
|
||||
return 'http://localhost:8080/dev-api/aitutor/aichat'; // H5环境API地址
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
// return 'http://192.168.31.123:8080/aitutor/aichat'; // 真机调试改成你的电脑 IP
|
||||
// #endif
|
||||
})();
|
||||
|
||||
/* ========== 组件 ========== */
|
||||
export default {
|
||||
components: {
|
||||
HistoryDrawer
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showHistoryDrawer: false, // 是否显示历史记录抽屉
|
||||
inputMessage: '', // 用户输入的消息
|
||||
messages: [], // 聊天消息列表
|
||||
scrollTop: 0, // 滚动位置
|
||||
conversation_id: null, // 当前会话ID
|
||||
showSingleReference: {}, // 控制引用来源的显示状态
|
||||
currentReader: null, // 当前流式读取器
|
||||
/* 新增锁 & 基础信息 */
|
||||
sending: false, // 是否正在发送消息
|
||||
user: uni.getStorageSync('stuNo') || '', // 用户学号
|
||||
userId: uni.getStorageSync('stuId') || '', // 用户ID
|
||||
userName: uni.getStorageSync('stuName') || '' // 用户名
|
||||
};
|
||||
},
|
||||
|
||||
/* ---------- 生命周期 ---------- */
|
||||
onLoad() {
|
||||
// 页面加载时检查登录状态
|
||||
if (!this.user) {
|
||||
this.$toast('请先登录');
|
||||
setTimeout(() => uni.navigateTo({
|
||||
url: '/pages/login/index'
|
||||
}), 1500);
|
||||
return;
|
||||
}
|
||||
this.initChat(); // 初始化聊天
|
||||
},
|
||||
onUnload() {
|
||||
// 页面卸载时取消流式读取
|
||||
this.currentReader?.cancel?.('页面卸载');
|
||||
this.currentReader = null;
|
||||
},
|
||||
async onPullDownRefresh() {
|
||||
// 下拉刷新
|
||||
await this.initChat();
|
||||
uni.stopPullDownRefresh();
|
||||
this.$toast('刷新成功', 'success');
|
||||
},
|
||||
|
||||
/* ---------- 方法 ---------- */
|
||||
methods: {
|
||||
// 返回首页
|
||||
goHome() {
|
||||
uni.reLaunch({
|
||||
url: '/pages/index/index'
|
||||
});
|
||||
},
|
||||
|
||||
// 切换历史记录抽屉显示状态
|
||||
toggleHistoryDrawer() {
|
||||
this.showHistoryDrawer = !this.showHistoryDrawer;
|
||||
},
|
||||
|
||||
// 渲染Markdown内容
|
||||
renderMarkdown(text) {
|
||||
return DOMPurify.sanitize(md.render(text || ''));
|
||||
},
|
||||
|
||||
/* 轻量 toast 封装 */
|
||||
$toast(title, icon = 'none') {
|
||||
uni.showToast({
|
||||
title,
|
||||
icon
|
||||
});
|
||||
},
|
||||
|
||||
/* 获取历史会话 or 欢迎语 */
|
||||
async initChat() {
|
||||
if (!this.user) return this.initConversation();
|
||||
try {
|
||||
// 请求获取用户历史消息
|
||||
const res = await uni.request({
|
||||
url: BASE_URL + '/getMessagesToUser',
|
||||
method: 'POST',
|
||||
data: {
|
||||
user: this.user
|
||||
}
|
||||
});
|
||||
const data = res.data || {};
|
||||
if (data.code === 200 && data.data?.conversationId) {
|
||||
// 设置会话ID和消息列表
|
||||
this.conversation_id = data.data.conversationId;
|
||||
uni.setStorageSync('conversation_id', this.conversation_id);
|
||||
this.messages = Array.isArray(data.data.messages) && data.data.messages.length ?
|
||||
data.data.messages :
|
||||
this.welcomeMessage();
|
||||
} else {
|
||||
this.initConversation();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取历史失败', e);
|
||||
this.$toast('连接失败,使用新会话');
|
||||
this.initConversation();
|
||||
}
|
||||
},
|
||||
|
||||
/* 欢迎语兜底 */
|
||||
welcomeMessage() {
|
||||
return [{
|
||||
sender: 'ai',
|
||||
avatar: '/static/AI.png',
|
||||
content: '你好!我是您的 AI 小助手,有什么可以帮您?😊',
|
||||
retrieverResources: [],
|
||||
image: ''
|
||||
}];
|
||||
},
|
||||
|
||||
// 初始化新会话
|
||||
initConversation() {
|
||||
uni.removeStorageSync('chatHistory');
|
||||
this.conversation_id = null;
|
||||
this.messages = this.welcomeMessage();
|
||||
},
|
||||
|
||||
/* 发送消息(带并发锁) */
|
||||
async sendMessage() {
|
||||
const msg = this.inputMessage.trim();
|
||||
if (!msg || this.sending) return;
|
||||
this.sending = true;
|
||||
|
||||
// 添加用户消息到列表
|
||||
this.messages.push({
|
||||
sender: 'user',
|
||||
avatar: '/static/yonghu.png',
|
||||
content: msg,
|
||||
image: '',
|
||||
messageId: Date.now().toString()
|
||||
});
|
||||
this.inputMessage = '';
|
||||
|
||||
// 添加AI占位消息
|
||||
const aiIdx = this.messages.push({
|
||||
sender: 'ai',
|
||||
avatar: '/static/AI.png',
|
||||
content: '',
|
||||
retrieverResources: [],
|
||||
image: '',
|
||||
messageId: null
|
||||
}) - 1;
|
||||
this.$set(this.showSingleReference, aiIdx, {});
|
||||
this.scrollToBottom();
|
||||
|
||||
try {
|
||||
// 创建流式聊天连接
|
||||
const {
|
||||
stream
|
||||
} = createChatStream({
|
||||
conversationId: this.conversation_id,
|
||||
prompt: msg,
|
||||
user: this.user,
|
||||
userId: this.userId,
|
||||
userName: this.userName
|
||||
});
|
||||
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 l of lines) {
|
||||
if (!l.startsWith('data:')) continue;
|
||||
const text = l.slice(5).trim();
|
||||
if (text === '[DONE]') continue;
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
// 处理消息内容
|
||||
if (json.event === 'message' && json.answer) {
|
||||
this.$set(this.messages[aiIdx], 'content', this.messages[aiIdx].content + json
|
||||
.answer);
|
||||
this.scrollToBottom();
|
||||
}
|
||||
// 处理消息结束事件
|
||||
if (json.event === 'message_end') {
|
||||
if (json['conversation id']) this.conversation_id = json['conversation id'];
|
||||
if (json.metadata?.retriever_resources) {
|
||||
this.$set(this.messages[aiIdx], 'retrieverResources', json.metadata
|
||||
.retriever_resources);
|
||||
this.$set(this.messages[aiIdx], 'messageId', json.message_id || Date.now()
|
||||
.toString());
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('JSON 解析失败', text, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('流式请求失败', e);
|
||||
this.$set(this.messages[aiIdx], 'content', '抱歉,AI 回复失败,请重试。');
|
||||
} finally {
|
||||
this.sending = false;
|
||||
this.scrollToBottom();
|
||||
uni.setStorageSync('chatHistory', this.messages);
|
||||
}
|
||||
},
|
||||
|
||||
/* 滚动到底部 */
|
||||
scrollToBottom() {
|
||||
this.$nextTick(() => {
|
||||
uni.createSelectorQuery()
|
||||
.in(this)
|
||||
.select('.message-list')
|
||||
.boundingClientRect(rect => {
|
||||
this.scrollTop = (rect?.height || 0) + 9999;
|
||||
})
|
||||
.exec();
|
||||
});
|
||||
},
|
||||
|
||||
// 切换单个引用来源的显示状态
|
||||
toggleSingleReference(msgIdx, refIdx) {
|
||||
if (!this.showSingleReference[msgIdx]) this.$set(this.showSingleReference, msgIdx, {});
|
||||
const cur = this.showSingleReference[msgIdx][refIdx];
|
||||
this.$set(this.showSingleReference[msgIdx], refIdx, !cur);
|
||||
},
|
||||
|
||||
// 点赞消息处理
|
||||
handleThumbUpClick(id) {
|
||||
if (!id) return;
|
||||
uni.request({
|
||||
url: BASE_URL + '/feedback',
|
||||
method: 'POST',
|
||||
data: {
|
||||
messageId: id,
|
||||
action: 1
|
||||
}
|
||||
}).then(() => this.$toast('点赞成功', 'success'))
|
||||
.catch(() => this.$toast('点赞失败'));
|
||||
},
|
||||
|
||||
// 点踩消息处理
|
||||
handleThumbDownClick(id) {
|
||||
if (!id) return;
|
||||
uni.request({
|
||||
url: BASE_URL + '/feedback',
|
||||
method: 'POST',
|
||||
data: {
|
||||
messageId: id,
|
||||
action: 0
|
||||
}
|
||||
}).then(() => this.$toast('已反馈'))
|
||||
.catch(() => this.$toast('反馈失败'));
|
||||
},
|
||||
|
||||
// 新建聊天会话
|
||||
newChat() {
|
||||
this.messages = [];
|
||||
this.conversation_id = null;
|
||||
uni.removeStorageSync('conversation_id');
|
||||
uni.removeStorageSync('chatHistory');
|
||||
this.initConversation();
|
||||
this.$toast('新聊天已开始');
|
||||
},
|
||||
|
||||
/* 选择图片并上传 */
|
||||
selectImage() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'], // 压缩
|
||||
sourceType: ['album', 'camera'],
|
||||
success: res => {
|
||||
const temp = res.tempFilePaths[0];
|
||||
// 上传图片
|
||||
uni.uploadFile({
|
||||
url: BASE_URL + '/files/upload',
|
||||
filePath: temp,
|
||||
name: 'file',
|
||||
formData: {
|
||||
user: this.user,
|
||||
userId: this.userId,
|
||||
userName: this.userName
|
||||
},
|
||||
success: ({
|
||||
data
|
||||
}) => {
|
||||
try {
|
||||
const {
|
||||
url
|
||||
} = JSON.parse(data);
|
||||
if (url) {
|
||||
// 添加图片消息到列表
|
||||
this.messages.push({
|
||||
sender: 'user',
|
||||
avatar: '/static/yonghu.png',
|
||||
content: '',
|
||||
image: url,
|
||||
messageId: Date.now().toString()
|
||||
});
|
||||
this.scrollToBottom();
|
||||
}
|
||||
} catch (e) {
|
||||
this.$toast('上传解析失败');
|
||||
}
|
||||
},
|
||||
fail: () => this.$toast('上传失败')
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 引入全局样式 */
|
||||
@import '@/static/scss/ai_index.css';
|
||||
</style>
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- index/index.vue -->
|
||||
<template>
|
||||
<view class="index">
|
||||
<!-- 头部个人信息 -->
|
||||
@@ -32,17 +33,17 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</workbench>
|
||||
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<view class="ai-hover" @click="showAI">
|
||||
<view class="ai-hover-content">
|
||||
<view class="ai-hover-text">AI</view>
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<view class="ai-hover" @click="toAI">
|
||||
<view class="ai-hover-content">
|
||||
<text class="ai-hover-text">AI</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- <view>{{log}}</view> -->
|
||||
<!-- <view>{{log}}</view> -->
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -169,7 +170,9 @@
|
||||
|
||||
// ]
|
||||
|
||||
log: "log"
|
||||
log: "log",
|
||||
// 新增:AI聊天显示状态,与聊天页同步
|
||||
isAIChatVisible: true
|
||||
}
|
||||
},
|
||||
onLoad(query) {
|
||||
@@ -184,9 +187,27 @@
|
||||
this.getQgzxLogExamineTotal()
|
||||
this.getqgzxMenoyTotal();
|
||||
}
|
||||
|
||||
// 从本地存储获取AI状态
|
||||
const aiStatus = uni.getStorageSync('aiVisibleStatus');
|
||||
if (aiStatus !== null && aiStatus !== undefined) {
|
||||
this.isAIChatVisible = aiStatus;
|
||||
} else {
|
||||
// 默认显示
|
||||
this.isAIChatVisible = true;
|
||||
uni.setStorageSync('aiVisibleStatus', true);
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
// 检查token是否存在
|
||||
if (!getToken()) {
|
||||
uni.showToast({
|
||||
title: '请先登录',
|
||||
icon: 'none'
|
||||
});
|
||||
uni.navigateTo({
|
||||
url: '/pages/login/index'
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.getUserInfo();
|
||||
@@ -194,6 +215,9 @@
|
||||
this.getUserRouters();
|
||||
this.getQgzxLogExamineTotal()
|
||||
this.getqgzxMenoyTotal();
|
||||
|
||||
// 页面显示时同步AI状态
|
||||
this.isAIChatVisible = uni.getStorageSync('aiVisibleStatus') || true;
|
||||
},
|
||||
methods: {
|
||||
getUserRouters() {
|
||||
@@ -264,21 +288,13 @@
|
||||
getImgUrl(name) {
|
||||
return require('../../static/images/workbench/' + name + '.png');
|
||||
},
|
||||
async showAI() {
|
||||
let userInfo = {
|
||||
roleGroup: uni.getStorageSync("roles"),
|
||||
nickName: this.nickName,
|
||||
username: this.username,
|
||||
avater: this.avater,
|
||||
user_token: getToken()
|
||||
}
|
||||
//1.获取token
|
||||
userInfo.accessToken = (await this.getAccessToken()).access_token;
|
||||
userInfo.onRefreshToken = async () => (await this.getAccessToken()).accessToken;
|
||||
// console.log("请求AI的信息", userInfo)
|
||||
const sdk = await initCoze(userInfo);
|
||||
sdk.showChatBot();
|
||||
|
||||
toAI() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/aiChat/ai_index'
|
||||
});
|
||||
},
|
||||
|
||||
async getAccessToken() {
|
||||
const res = await getAccessToken(); // 调用请求函数
|
||||
const data = JSON.parse(res.data); // 解析数据
|
||||
@@ -387,7 +403,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ai悬停
|
||||
.ai-hover {
|
||||
position: fixed;
|
||||
@@ -403,5 +418,24 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.ai-hover-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ai-hover-text {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ai-hover:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
BIN
static/AI.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
1
static/add.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1754037771551" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8573" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M972.8 460.8H51.2c-28.16 0-51.2 23.04-51.2 51.2s23.04 51.2 51.2 51.2h921.6c28.16 0 51.2-23.04 51.2-51.2s-23.04-51.2-51.2-51.2z" fill="#4F46E5" p-id="8574"></path><path d="M512 0c-28.16 0-51.2 23.04-51.2 51.2v921.6c0 28.16 23.04 51.2 51.2 51.2s51.2-23.04 51.2-51.2V51.2c0-28.16-23.04-51.2-51.2-51.2z" fill="#4F46E5" p-id="8575"></path></svg>
|
||||
|
After Width: | Height: | Size: 673 B |
1
static/good.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1753693561642" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11887" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M64 483.04V872c0 37.216 30.144 67.36 67.36 67.36H192V416.32l-60.64-0.64A67.36 67.36 0 0 0 64 483.04zM857.28 344.992l-267.808 1.696c12.576-44.256 18.944-83.584 18.944-118.208 0-78.56-68.832-155.488-137.568-145.504-60.608 8.8-67.264 61.184-67.264 126.816v59.264c0 76.064-63.84 140.864-137.856 148L256 416.96v522.4h527.552a102.72 102.72 0 0 0 100.928-83.584l73.728-388.96a102.72 102.72 0 0 0-100.928-121.824z" p-id="11888" fill="#4F46E5"></path></svg>
|
||||
|
After Width: | Height: | Size: 782 B |
1
static/history.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1754032734502" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5374" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M0 505.135c0-0.723 0.723-2.168 0.723-2.89 3.613-18.79 13.007-33.243 29.628-42.637 7.227-4.336 15.176-5.782 23.125-6.504H961.13c20.234 0 35.41 8.672 46.973 24.57 8.671 12.285 12.285 25.293 10.84 40.469-2.891 23.124-18.79 43.359-42.637 49.14-6.504 1.445-13.008 2.168-20.234 2.168H58.535c-23.848 0-39.746-11.563-51.308-31.074-3.614-5.781-5.059-12.285-6.504-18.79 0-0.722-0.723-2.167-0.723-2.89v-11.562zM0 205.234c0-0.723 0.723-2.168 0.723-2.891 3.613-18.789 13.007-33.242 29.628-42.637 7.227-4.335 15.176-5.78 23.125-6.503H961.13c20.234 0 35.41 8.671 46.973 24.57 8.671 12.285 12.285 25.293 10.84 40.468-2.891 23.125-18.79 43.36-42.637 49.14-6.504 1.446-13.008 2.169-20.234 2.169H58.535c-23.848 0-39.746-11.563-51.308-31.074-3.614-5.782-5.059-12.285-6.504-18.79 0-1.445-0.723-2.89-0.723-3.613v-10.84zM51.308 862.848c-1.445 0-2.168-0.722-3.613-0.722-16.62-3.614-28.183-13.008-36.855-27.461-6.504-10.84-9.395-22.402-8.672-34.688 2.168-24.57 19.512-46.25 44.804-52.03 5.06-1.446 10.84-1.446 16.622-1.446h899.703c19.512 0 34.687 8.672 46.25 23.848 7.226 10.117 11.562 22.402 11.562 34.687 0 14.453-5.058 27.46-14.453 38.3-9.394 10.84-21.68 17.344-36.132 18.79-0.723 0-1.446 0-2.168 0.722H51.308zM1024 189.335c-0.723-2.168-0.723-5.058-1.445-7.226l1.445 7.226z" fill="#000000" p-id="5375"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
1
static/newChat.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1754032784723" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7240" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M275.413 810.667L85.333 960V170.667A42.667 42.667 0 0 1 128 128h768a42.667 42.667 0 0 1 42.667 42.667V768A42.667 42.667 0 0 1 896 810.667H275.413z m193.92-384h-128V512h128v128h85.334V512h128v-85.333h-128v-128h-85.334v128z" fill="#2c2c2c" p-id="7241"></path></svg>
|
||||
|
After Width: | Height: | Size: 596 B |
363
static/scss/ai_index.css
Normal file
@@ -0,0 +1,363 @@
|
||||
/* ai_index.css */
|
||||
/* ============= 整体容器 ============= */
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
/* 改为最小高度,允许内容撑开 */
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background-color: #F5F5F5;
|
||||
padding-top: 10px;
|
||||
/* 为固定导航栏预留空间 */
|
||||
box-sizing: border-box;
|
||||
/* 确保padding不会增加总宽度 */
|
||||
}
|
||||
|
||||
/* ============= 状态栏占位 ============= */
|
||||
.status-bar-placeholder {
|
||||
height: var(--status-bar-height);
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
/* ============= 自定义导航栏(固定在顶部) ============= */
|
||||
.custom-nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.1);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
border-bottom: 1px solid #eee;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.nav-left,
|
||||
.nav-right {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ============= 消息列表(可滚动区域) ============= */
|
||||
.message-list {
|
||||
flex: 1;
|
||||
padding: 16px 0 calc(env(safe-area-inset-bottom) + 80px) 0;
|
||||
/* 增加底部内边距 */
|
||||
background-color: #f5f5f5;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
/* 确保内容超出时可滚动 */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
/* 增加iOS设备上的滚动流畅度 */
|
||||
}
|
||||
|
||||
/* ============= 消息项 ============= */
|
||||
.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;
|
||||
}
|
||||
|
||||
.sent-image {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
margin-top: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* ============= AI 提示区域 ============= */
|
||||
.ai-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.quote-icon {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.ai-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ai-text {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.icon-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ============= 输入框区域(固定在底部) ============= */
|
||||
.input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background-color: #fff;
|
||||
border-top: 1px solid #eee;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 10px);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
.input-container input {
|
||||
flex: 1;
|
||||
max-width: 90%;
|
||||
/* 缩短输入框 */
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
margin: 0 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.add-icon,
|
||||
.send-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
margin: 0 12px;
|
||||
/* 增加图标之间的间距 */
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
margin-top: 10px;
|
||||
color: blue;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.reference-details {
|
||||
margin-top: 10px;
|
||||
padding-left: 20px;
|
||||
border-left: 2px solid #ccc;
|
||||
}
|
||||
|
||||
/* md格式 */
|
||||
.markdown-content {
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.markdown-content h1,
|
||||
.markdown-content h2,
|
||||
.markdown-content h3 {
|
||||
margin: 1em 0 0.5em;
|
||||
}
|
||||
|
||||
.markdown-content p {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
background-color: #f3f4f6;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
background-color: #f6f8fa;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
overflow-x: auto;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol {
|
||||
margin-left: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
border-left: 4px solid #d0d7de;
|
||||
color: #57606a;
|
||||
margin: 1em 0;
|
||||
padding-left: 1em;
|
||||
}
|
||||
|
||||
|
||||
/* 可点击文档名 */
|
||||
.doc-name-link {
|
||||
color: #007AFF;
|
||||
text-decoration: underline;
|
||||
margin-right: 16rpx;
|
||||
font-size: 10rpx;
|
||||
}
|
||||
|
||||
/* 每个引用项容器 */
|
||||
.reference-item-wrapper {
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
/* ============= 引用详情 - 自适应字体 ============= */
|
||||
|
||||
.reference-details-item {
|
||||
margin-top: 12rpx;
|
||||
padding: 20rpx;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8rpx;
|
||||
border: 1rpx solid #e0e0e0;
|
||||
font-size: clamp(12px, 2.8vw, 16px);
|
||||
color: #555;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.reference-meta {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
font-size: clamp(13px, 3.2vw, 17px);
|
||||
}
|
||||
|
||||
.reference-content {
|
||||
color: #444;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.doc-name-link {
|
||||
color: #007AFF;
|
||||
text-decoration: underline;
|
||||
margin-right: 16rpx;
|
||||
font-size: clamp(13px, 3vw, 15px);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* AI悬浮按钮样式 - 全局显示 */
|
||||
.ai-hover {
|
||||
position: fixed;
|
||||
right: 40rpx;
|
||||
bottom: 120rpx;
|
||||
z-index: 9999;
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #409eff;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.ai-hover-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ai-hover-text {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ai-hover:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* ============= 小屏设备适配 ============= */
|
||||
@media (max-width: 600px) {
|
||||
.message-content {
|
||||
max-width: 65%;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
1
static/search.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1754534613016" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6268" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M948.48 833.92l-185.6-183.68c-3.84-3.84-8.32-6.4-13.44-7.68C801.28 580.48 832 501.76 832 416 832 221.44 674.56 64 480 64 285.44 64 128 221.44 128 416 128 610.56 285.44 768 480 768c85.76 0 163.84-30.72 225.28-81.28 1.92 4.48 4.48 8.96 8.32 12.8l185.6 183.68c14.08 13.44 35.84 13.44 49.92 0S962.56 847.36 948.48 833.92zM480 704C320.64 704 192 575.36 192 416 192 256.64 320.64 128 480 128 639.36 128 768 256.64 768 416 768 575.36 639.36 704 480 704z" p-id="6269"></path></svg>
|
||||
|
After Width: | Height: | Size: 806 B |
1
static/send.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1754040545229" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1872" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M24.649143 399.36L965.485714 7.314286a29.257143 29.257143 0 0 1 39.643429 34.084571l-234.349714 937.472a29.257143 29.257143 0 0 1-47.104 15.36l-203.483429-169.545143a29.257143 29.257143 0 0 0-39.424 1.828572l-104.374857 104.301714a29.257143 29.257143 0 0 1-49.883429-20.626286V689.737143a29.257143 29.257143 0 0 1 8.557715-20.699429l424.448-424.448-501.101715 375.881143a29.257143 29.257143 0 0 1-36.278857-0.950857L17.188571 448.804571a29.257143 29.257143 0 0 1 7.460572-49.444571z" p-id="1873" fill="#4F46E5"></path></svg>
|
||||
|
After Width: | Height: | Size: 857 B |
1
static/tread.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1753693581732" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="13840" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M611.188364 651.962182h226.56a93.090909 93.090909 0 0 0 91.834181-108.334546l-61.905454-372.689454A93.090909 93.090909 0 0 0 775.889455 93.090909H372.968727v558.871273c82.152727 81.338182 72.866909 210.571636 88.832 242.338909 15.941818 31.767273 47.616 36.119273 55.621818 36.608 39.703273 0 179.665455-32.395636 93.789091-278.946909zM313.832727 651.636364V93.090909H202.891636a93.090909 93.090909 0 0 0-92.997818 88.901818l-16.709818 372.363637A93.090909 93.090909 0 0 0 186.181818 651.636364h127.650909z" fill="#4F46E5" p-id="13841"></path></svg>
|
||||
|
After Width: | Height: | Size: 883 B |
1
static/voice.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1753693546032" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9939" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M216.064 418.816c-50.176 0-91.136 40.96-91.136 91.136s40.96 91.136 91.136 91.136 91.136-40.96 91.136-91.136-40.96-91.136-91.136-91.136zM415.232 815.104c-21.504 0-42.496-8.704-57.344-25.6-27.648-31.744-24.064-79.36 7.168-107.52 50.176-43.52 78.848-106.496 78.848-172.032 0-64.512-27.648-125.952-75.776-168.96-31.232-28.16-33.792-76.288-5.632-107.52s76.288-33.792 107.52-5.632c79.36 71.68 125.44 174.592 125.44 281.6 0 109.568-47.616 214.016-130.56 286.208-14.336 13.312-31.744 19.456-49.664 19.456z" p-id="9940" fill="#4F46E5"></path><path d="M601.088 985.088c-30.208-29.184-31.232-77.312-2.048-107.52 95.744-99.328 148.48-229.888 148.48-367.616 0-136.192-51.2-265.216-144.896-364.544-28.672-30.72-27.648-78.848 3.072-107.52s78.848-27.648 107.52 3.072c120.32 126.976 186.368 293.888 186.368 468.48 0 177.664-67.584 345.6-190.464 473.088-14.848 15.36-35.328 23.04-54.784 23.04-19.456 1.024-38.4-6.144-53.248-20.48z" p-id="9941" fill="#4F46E5"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
static/yonghu.png
Normal file
|
After Width: | Height: | Size: 518 KiB |
43
utils/ai_request.js
Normal file
@@ -0,0 +1,43 @@
|
||||
// src/utils/ai_request.js
|
||||
import axios from 'axios'
|
||||
import {
|
||||
getToken
|
||||
} from './auth'
|
||||
|
||||
const service = axios.create({
|
||||
// baseURL: 'http://localhost:9090/dev-api/aitutor/aichat',
|
||||
baseURL: 'http://localhost:8088/aitutor/aichat',
|
||||
// baseURL: 'http://localhost:8080/aitutor/aichat',
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
// 请求拦截器:统一加 token
|
||||
service.interceptors.request.use(
|
||||
config => {
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
service.interceptors.response.use(
|
||||
res => res.data,
|
||||
err => {
|
||||
if (err.response?.status === 401) {
|
||||
uni.showToast({
|
||||
title: '未登录或 token 失效',
|
||||
icon: 'none'
|
||||
})
|
||||
uni.navigateTo({
|
||||
url: '/pages/login/index'
|
||||
})
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
export default service
|
||||
57
utils/ai_stream.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// src/utils/ai_stream.js (H5 优化版)
|
||||
import {
|
||||
getToken
|
||||
} from '@/utils/auth';
|
||||
|
||||
const BASE_URL = (() => {
|
||||
// #ifdef H5
|
||||
return 'http://localhost:8088';
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
// return 'http://192.168.x.x:8088'; // 换成你的电脑 IP
|
||||
// #endif
|
||||
})();
|
||||
|
||||
export function createChatStream(params) {
|
||||
const requestId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const url = `${BASE_URL}/aitutor/aichat/stream`;
|
||||
const token = getToken();
|
||||
if (!token) throw new Error('请先登录');
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
const fetchPromise = fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'text/event-stream',
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'X-Request-ID': requestId
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: params.prompt,
|
||||
user_id: params.userId,
|
||||
user_name: params.userName,
|
||||
user_token: params.user_token || '123',
|
||||
user_role: 'student',
|
||||
conversation_id: params.conversationId || null,
|
||||
}),
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(resp => {
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
if (!resp.body) throw new Error('Response body is null');
|
||||
return resp.body;
|
||||
});
|
||||
|
||||
return fetchPromise.then(body => ({
|
||||
stream: Promise.resolve({
|
||||
reader: body.getReader(),
|
||||
decoder: new TextDecoder('utf-8')
|
||||
}),
|
||||
cancel: reason => !controller.signal.aborted && controller.abort(reason)
|
||||
})).catch(err => ({
|
||||
stream: Promise.reject(err),
|
||||
cancel: () => {}
|
||||
}));
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// auth.js
|
||||
const TokenKey = 'App-Token'
|
||||
|
||||
export function getToken() {
|
||||
|
||||