AI弹窗
This commit is contained in:
499
src/components/aiChat/HistoryDrawer.vue
Normal file
499
src/components/aiChat/HistoryDrawer.vue
Normal file
@@ -0,0 +1,499 @@
|
||||
<template>
|
||||
<!-- 抽屉容器,visible控制显示 -->
|
||||
<div v-if="visible" class="drawer-container" @touchmove.prevent>
|
||||
<!-- 遮罩层,点击关闭 -->
|
||||
<div class="drawer-mask" @click="closeDrawer"></div>
|
||||
|
||||
<!-- 抽屉内容区域 -->
|
||||
<div class="drawer-content">
|
||||
<!-- 标题区域 -->
|
||||
<div class="drawer-header">
|
||||
<span class="title">历史记录</span>
|
||||
<img src="@/assets/close.svg" class="close-icon" @click="closeDrawer" />
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar">
|
||||
<img src="@/assets/search.svg" class="search-icon" />
|
||||
<input v-model="searchKeyword" placeholder="搜索聊天记录..." class="search-input" @input="handleSearch" />
|
||||
<img v-if="searchKeyword" src="@/assets/clear.svg" class="clear-icon" @click="clearSearch" />
|
||||
</div>
|
||||
|
||||
<!-- 历史列表区域 -->
|
||||
<div class="history-list">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-tip">加载中...</div>
|
||||
|
||||
<!-- 分组渲染历史记录 -->
|
||||
<template v-else-if="filteredRecords.length > 0">
|
||||
<template v-for="(group, groupIndex) in filteredRecords" :key="groupIndex">
|
||||
<div class="group-title">{{ group.title }}</div>
|
||||
<div v-for="item in group.list" :key="item.id" class="history-item"
|
||||
@click="handleItemClick(item)">
|
||||
<!-- 日期时间显示 -->
|
||||
<div class="datetime">
|
||||
<span class="date">{{ item.date }}</span>
|
||||
<span class="time">{{ item.time }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 消息内容 -->
|
||||
<div class="record">
|
||||
<span class="user-msg" v-html="highlightKeyword(item.content)"></span>
|
||||
<span class="ai-msg" v-html="highlightKeyword(item.reply)"></span>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 空状态提示 -->
|
||||
<div v-else class="empty-tip">
|
||||
<span>{{ searchKeyword ? '没有找到匹配的记录' : '暂无聊天记录' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, watch } from 'vue'
|
||||
import { getHistory } from '@/api/aiChat/ai_index'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
export default {
|
||||
name: 'HistoryDrawer',
|
||||
props: {
|
||||
/** 是否显示抽屉 */
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
/** 关闭抽屉事件 */
|
||||
'close',
|
||||
/** 点击历史记录项事件 */
|
||||
'item-click'
|
||||
],
|
||||
setup(props, { emit }) {
|
||||
// 工具函数
|
||||
const { showToast } = useToast()
|
||||
|
||||
// 响应式数据
|
||||
const historyRecords = ref([]) // 所有历史记录(分组后)
|
||||
const filteredRecords = ref([]) // 筛选后的历史记录
|
||||
const searchKeyword = ref('') // 搜索关键词
|
||||
const loading = ref(false) // 加载状态
|
||||
|
||||
// 监听抽屉显示状态,显示时加载数据,隐藏时清空搜索
|
||||
watch(
|
||||
() => props.visible,
|
||||
(isVisible) => {
|
||||
if (isVisible) {
|
||||
loadHistoryRecords()
|
||||
} else {
|
||||
clearSearch()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 关闭抽屉
|
||||
*/
|
||||
const closeDrawer = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载历史记录
|
||||
*/
|
||||
const loadHistoryRecords = async () => {
|
||||
// 显示加载状态
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// 获取用户标识
|
||||
const userNo = localStorage.getItem('stuNo')
|
||||
if (!userNo) {
|
||||
throw new Error('未获取到用户学号')
|
||||
}
|
||||
|
||||
// 调用API获取历史记录
|
||||
const response = await getHistory({
|
||||
user: userNo,
|
||||
conversationId: '',
|
||||
limit: 20
|
||||
})
|
||||
|
||||
// 处理获取到的数据
|
||||
const rawList = Array.isArray(response.data?.data) ? response.data.data : []
|
||||
const groupedRecords = groupRecordsByTime(rawList)
|
||||
|
||||
// 更新记录数据
|
||||
historyRecords.value = groupedRecords
|
||||
filteredRecords.value = [...groupedRecords]
|
||||
} catch (error) {
|
||||
console.error('加载历史记录失败:', error)
|
||||
showToast(`加载失败: ${error.message}`, 'error')
|
||||
historyRecords.value = []
|
||||
filteredRecords.value = []
|
||||
} finally {
|
||||
// 隐藏加载状态
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将原始记录按时间分组
|
||||
* @param {Array} rawList - 原始记录列表
|
||||
* @returns {Array} 分组后的记录
|
||||
*/
|
||||
const groupRecordsByTime = (rawList) => {
|
||||
const groupMap = {}
|
||||
|
||||
rawList.forEach(item => {
|
||||
// 处理时间戳,兼容不同字段名
|
||||
const timestamp = item.created_at || item.create_time || item.timestamp || Date.now() / 1000
|
||||
const recordDate = new Date(timestamp * 1000)
|
||||
|
||||
// 格式化记录数据
|
||||
const record = {
|
||||
id: item.id || Math.random().toString(36).slice(2),
|
||||
date: formatDate(recordDate),
|
||||
time: formatTime(recordDate),
|
||||
content: item.query || item.content || '未知内容',
|
||||
reply: item.answer || item.reply || '暂无回复',
|
||||
timestamp: timestamp
|
||||
}
|
||||
|
||||
// 按时间分组
|
||||
const groupTitle = getGroupTitle(recordDate)
|
||||
if (!groupMap[groupTitle]) {
|
||||
groupMap[groupTitle] = []
|
||||
}
|
||||
groupMap[groupTitle].push(record)
|
||||
})
|
||||
|
||||
// 转换为数组并排序
|
||||
return Object.entries(groupMap)
|
||||
.map(([title, list]) => ({
|
||||
title,
|
||||
// 按时间倒序排列(最新的在前)
|
||||
list: list.sort((a, b) => b.timestamp - a.timestamp)
|
||||
}))
|
||||
// 按分组标题排序(今天、昨天、7天内、30天内、更早)
|
||||
.sort((a, b) => getGroupOrder(b.title) - getGroupOrder(a.title))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分组的排序优先级
|
||||
* @param {String} title - 分组标题
|
||||
* @returns {Number} 排序优先级(数字越大越靠前)
|
||||
*/
|
||||
const getGroupOrder = (title) => {
|
||||
const orderMap = {
|
||||
'今天': 5,
|
||||
'昨天': 4,
|
||||
'7天内': 3,
|
||||
'30天内': 2,
|
||||
'更早': 1
|
||||
}
|
||||
return orderMap[title] || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理搜索
|
||||
*/
|
||||
const handleSearch = () => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||
|
||||
// 关键词为空时显示所有记录
|
||||
if (!keyword) {
|
||||
filteredRecords.value = [...historyRecords.value]
|
||||
return
|
||||
}
|
||||
|
||||
// 根据关键词筛选记录
|
||||
filteredRecords.value = historyRecords.value
|
||||
.map(group => ({
|
||||
...group,
|
||||
list: group.list.filter(item =>
|
||||
item.content.toLowerCase().includes(keyword) ||
|
||||
item.reply.toLowerCase().includes(keyword)
|
||||
)
|
||||
}))
|
||||
.filter(group => group.list.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空搜索
|
||||
*/
|
||||
const clearSearch = () => {
|
||||
searchKeyword.value = ''
|
||||
filteredRecords.value = [...historyRecords.value]
|
||||
}
|
||||
|
||||
/**
|
||||
* 高亮显示关键词
|
||||
* @param {String} text - 原始文本
|
||||
* @returns {String} 处理后的HTML文本
|
||||
*/
|
||||
const highlightKeyword = (text) => {
|
||||
if (!searchKeyword.value || !text) {
|
||||
return text
|
||||
}
|
||||
|
||||
const keyword = searchKeyword.value.trim()
|
||||
return text.replace(
|
||||
new RegExp(keyword, 'gi'),
|
||||
`<span class="highlight">${keyword}</span>`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取记录的分组标题(今天、昨天等)
|
||||
* @param {Date} date - 记录日期
|
||||
* @returns {String} 分组标题
|
||||
*/
|
||||
const getGroupTitle = (date) => {
|
||||
const now = new Date()
|
||||
const today = new Date(now.setHours(0, 0, 0, 0))
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(today.getDate() - 1)
|
||||
const oneWeekAgo = new Date(today)
|
||||
oneWeekAgo.setDate(today.getDate() - 7)
|
||||
const oneMonthAgo = new Date(today)
|
||||
oneMonthAgo.setDate(today.getDate() - 30)
|
||||
|
||||
if (date >= today) return '今天'
|
||||
if (date >= yesterday) return '昨天'
|
||||
if (date >= oneWeekAgo) return '7天内'
|
||||
if (date >= oneMonthAgo) return '30天内'
|
||||
return '更早'
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期为YYYY-MM-DD
|
||||
* @param {Date} date - 日期对象
|
||||
* @returns {String} 格式化后的日期字符串
|
||||
*/
|
||||
const formatDate = (date) => {
|
||||
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')
|
||||
}-${date.getDate().toString().padStart(2, '0')
|
||||
}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间为HH:MM
|
||||
* @param {Date} date - 日期对象
|
||||
* @returns {String} 格式化后的时间字符串
|
||||
*/
|
||||
const formatTime = (date) => {
|
||||
return `${date.getHours().toString().padStart(2, '0')
|
||||
}:${date.getMinutes().toString().padStart(2, '0')
|
||||
}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理历史记录项点击
|
||||
* @param {Object} item - 记录项数据
|
||||
*/
|
||||
const handleItemClick = (item) => {
|
||||
// 移除HTML标签,避免传递富文本
|
||||
emit('item-click', {
|
||||
...item,
|
||||
content: item.content.replace(/<[^>]+>/g, ''),
|
||||
reply: item.reply.replace(/<[^>]+>/g, '')
|
||||
})
|
||||
closeDrawer()
|
||||
}
|
||||
|
||||
return {
|
||||
historyRecords,
|
||||
filteredRecords,
|
||||
searchKeyword,
|
||||
loading,
|
||||
closeDrawer,
|
||||
handleSearch,
|
||||
clearSearch,
|
||||
highlightKeyword,
|
||||
handleItemClick
|
||||
}
|
||||
}
|
||||
}
|
||||
</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;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
background-color: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 0 15px;
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.history-item:hover {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.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,
|
||||
.loading-tip {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: #ccc;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
Reference in New Issue
Block a user