Files
fad_oa/ruoyi-ui/src/layout/components/AIChat/index.vue
2026-04-13 17:04:38 +08:00

545 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div>
<svg-icon icon-class="ai-chat" @click="openChat" />
<el-drawer title="AI对话" :visible.sync="isChatVisible" :show-close="false" direction="rtl" size="60%">
<template slot="title">
<div class="ai-chat-title">
<div>AI对话</div>
<div style="display: flex; align-items: center;">
<div class="icon-wrapper" title="新建对话" @click="newChat">
<i class="el-icon-plus"></i>
</div>
<div class="icon-wrapper" title="历史对话" @click="showHistory = true">
<i class="el-icon-time"></i>
</div>
<div class="icon-wrapper" title="关闭对话" @click="closeChat">
<i class="el-icon-close"></i>
</div>
<!-- 历史对话弹窗 -->
<el-dialog :visible.sync="showHistory" :append-to-body="true" width="400px"
custom-class="history-dialog" @open="handleHistoryDialogOpen">
<div slot="title" class="dialog-title">
<div class="title-left">
<span>历史对话</span>
<i class="el-icon-refresh" @click="refreshHistory" :class="{'is-loading': isLoadingHistory}" title="刷新"></i>
</div>
</div>
<div v-loading="isLoadingHistory" class="history-list">
<template v-if="chatHistory.length === 0 && !isLoadingHistory">
<div class="empty-history">
暂无历史对话
</div>
</template>
<template v-else>
<div v-for="(chat, index) in chatHistory" :key="index" class="history-item"
:class="{ 'active': currentChatId === chat.id }" @click="loadChat(chat)">
<div class="history-item-content" v-loading="chat.loading" element-loading-background="rgba(255, 255, 255, 0.7)">
<div class="history-title">{{ chat.title || '未命名对话' }}</div>
<div class="history-time">{{ chat.time }}</div>
</div>
</div>
</template>
</div>
</el-dialog>
</div>
</div>
</template>
<div class="ai-chat-container">
<!-- 消息列表 -->
<div class="message-list">
<div v-for="(msg, index) in messages" :key="index"
:class="['message-item', msg.type === 'user' ? 'user-message' : 'system-message']">
<div class="message-avatar">
<img v-if="msg.type === 'user'" :src="avatar" />
<i v-else class="el-icon-service"></i>
</div>
<div class="message-content">
<div class="message-text" :class="{ 'loading-message': msg.isLoading }">
<template v-if="msg.isLoading">
<i class="el-icon-loading"></i>
{{ msg.content }}
</template>
<template v-else>
<ChartRenderer v-if="msg.renderType == 'chart'" :chartOptions="msg.content.options" :dataset="msg.content.dataset"/>
<TableRenderer v-else-if="msg.renderType == 'table'" :columns="msg.content.columns" :datasource="msg.content.datasource"/>
<TextRenderer v-else-if="msg.type === 'system'" :content="msg.content" />
<template v-else>{{ msg.content }}</template>
</template>
</div>
<div class="message-time">{{ msg.time }}</div>
</div>
</div>
</div>
<!-- 消息发送和操作栏 -->
<div class="message-input">
<!-- 快捷操作栏: 例如分析数据生成报告生成代码等,主要是构建不同的提示词快速发送消息 -->
<div class="quick-actions">
<steer @submit="handleSteerSubmit" />
</div>
<el-input type="textarea" v-model="message" placeholder="请输入内容" :rows="3" class="message-textarea" />
<el-button type="primary" @click="handleSendMessage" :disabled="!message.trim()">发送</el-button>
</div>
</div>
</el-drawer>
</div>
</template>
<script>
import { dataAnalysis, getConversationDetail, getConversationList, newConversation, sendMessage } from '@/api/oa/ai';
import { mapGetters } from 'vuex';
import ChartRenderer from './components/renderer/chart/index.vue';
import TableRenderer from './components/renderer/table/index.vue';
import TextRenderer from './components/renderer/text/index.vue';
import steer from './components/steer/analysic.vue';
export default {
name: 'AIChat',
components: {
steer,
TextRenderer,
ChartRenderer,
TableRenderer
},
data () {
return {
isChatVisible: false,
showHistory: false,
message: '',
messages: [],
isResponding: false,
chatHistory: [],
isLoadingHistory: false,
historyLoaded: false,
currentChatId: null
}
},
computed: {
...mapGetters(["avatar"]),
},
created() {},
updated () {
this.scrollToBottom();
},
methods: {
openChat () {
this.isChatVisible = true;
},
closeChat () {
this.isChatVisible = false;
},
newChat () {
this.currentChatId = null;
this.messages = [];
this.showHistory = false;
},
scrollToBottom () {
const messageList = this.$el.querySelector('.message-list');
if (messageList) {
setTimeout(() => {
messageList.scrollTop = messageList.scrollHeight;
}, 50);
}
},
handleSteerSubmit (text) {
// 显示正在回复状态
this.isResponding = true;
this.messages.push({
type: 'system',
content: '正在思考中...',
time: new Date().toLocaleTimeString(),
isLoading: true
});
dataAnalysis(text).then(res => {
// 移除loading消息
this.messages = this.messages.filter(msg => !msg.isLoading);
const systemMessage = {
type: 'system',
content: res.data.response,
renderType: res.data.renderType,
time: new Date().toLocaleTimeString()
};
this.messages.push(systemMessage);
this.isResponding = false;
});
},
async handleSendMessage () {
if (!this.message.trim()) return;
// 如果是第一条消息,则先创建对话
if (!this.currentChatId) {
// 如果消息很长需要截取前20个字符
const message = this.message.length > 20 ? this.message.slice(0, 20) : this.message;
const res = await newConversation(message);
this.currentChatId = res.data.conversationId;
}
const userMessage = this.message;
this.message = '';
// 添加用户消息
const newMessage = {
type: 'user',
content: userMessage,
time: new Date().toLocaleTimeString()
};
this.messages.push(newMessage);
// 显示正在回复状态
this.isResponding = true;
this.messages.push({
type: 'system',
content: '正在思考中...',
time: new Date().toLocaleTimeString(),
isLoading: true
});
const res = await sendMessage({
conversationId: this.currentChatId,
message: userMessage
});
// 移除loading消息
this.messages = this.messages.filter(msg => !msg.isLoading);
// 添加系统回复
// 根据res.data.renderType来区分回复类型
// 如果是text则直接显示文本
// 如果是fix则是混合内容需要调用多个renderer
// 如果是chart则是图表
// 如果是table则是表格
const systemMessage = {
type: 'system',
content: res.data.response,
renderType: res.data.renderType,
time: new Date().toLocaleTimeString()
};
this.messages.push(systemMessage);
this.isResponding = false;
},
async handleSendTest() {
this.testRenderer(this.message);
},
async testRenderer (type) {
const systemMessage = {
type: 'system',
renderType: type,
time: new Date().toLocaleTimeString()
};
switch (type) {
case 'text':
systemMessage.content = '测试文本';
break;
case 'chart':
systemMessage.content = {
options: {
title: {
text: '测试图表'
},
},
dataset: [
{
name: '测试数据',
data: [1, 2, 3, 4, 5]
}
]
};
break;
case 'table':
systemMessage.content = {
columns: [
{
title: '姓名',
dataIndex: 'name'
},
{
title: '年龄',
dataIndex: 'age'
},
{
title: '性别',
dataIndex: 'gender'
}
],
datasource: [{ name: '张三', age: 18, gender: '男' }, { name: '李四', age: 20, gender: '女' }, { name: '王五', age: 22, gender: '男' }]
};
break;
}
console.log('测试渲染器:', systemMessage);
this.messages.push(systemMessage);
},
async handleHistoryDialogOpen() {
if (!this.historyLoaded) {
await this.refreshHistory();
}
},
async refreshHistory() {
try {
this.isLoadingHistory = true;
const res = await getConversationList();
this.chatHistory = res.data.map((chat, idx) => ({
id: chat.conversationId,
title: chat.conversationTitle || '未命名对话' + (idx + 1),
time: chat.createTime,
loading: false
}));
this.historyLoaded = true;
} catch (error) {
this.$message.error('获取历史对话失败');
} finally {
this.isLoadingHistory = false;
}
},
async loadChat (chat) {
try {
chat.loading = true;
this.showHistory = false;
const res = await getConversationDetail(chat.id);
this.currentChatId = chat.id;
// 将消息列表转换为本地格式
this.messages = res.data.map(msg => ({
type: msg.role === 'user' ? 'user' : 'system',
content: msg.content,
renderType: msg.renderType || 'text',
time: msg.createTime
}));
} catch (error) {
this.$message.error('加载对话内容失败');
return;
} finally {
chat.loading = false;
}
},
analyzeData () {
console.log('分析数据');
this.message = '分析数据';
this.handleSendMessage();
},
generateReport () {
console.log('生成报告');
this.message = '生成报告';
this.handleSendMessage();
},
generateCode () {
console.log('生成代码');
this.message = '生成代码';
this.handleSendMessage();
},
}
}
</script>
<style scoped>
.ai-chat-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.ai-chat-container {
height: calc(100vh - 120px);
display: flex;
flex-direction: column;
padding: 20px;
}
.message-list {
flex: 1;
overflow-y: auto;
margin-bottom: 20px;
}
.message-item {
display: flex;
margin-bottom: 20px;
padding: 10px;
}
.user-message {
flex-direction: row-reverse;
}
.message-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #f0f2f5;
display: flex;
align-items: center;
justify-content: center;
margin: 0 10px;
overflow: hidden;
}
.message-avatar img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.message-content {
max-width: 60%;
}
.message-text {
padding: 12px 16px;
border-radius: 8px;
word-wrap: break-word;
}
.loading-message {
color: #909399;
}
.loading-message .el-icon-loading {
margin-right: 8px;
}
.user-message .message-text {
background-color: #409EFF;
color: white;
}
.system-message .message-text {
background-color: #f0f2f5;
color: #333;
}
.message-time {
font-size: 12px;
color: #999;
margin-top: 4px;
text-align: right;
}
.message-input {
border-top: 1px solid #eee;
padding-top: 20px;
}
.message-textarea {
margin-bottom: 10px;
}
.user-message .message-content {
margin-right: 10px;
}
.system-message .message-content {
margin-left: 10px;
}
.history-dialog {
border-radius: 8px;
}
.dialog-title {
display: flex;
align-items: center;
}
.dialog-title .title-left {
display: flex;
align-items: center;
gap: 8px;
}
.dialog-title .el-icon-refresh {
cursor: pointer;
font-size: 14px;
padding: 4px;
border-radius: 4px;
transition: all 0.3s;
color: #909399;
}
.dialog-title .el-icon-refresh:hover {
background-color: #f5f7fa;
}
.dialog-title .el-icon-refresh.is-loading {
animation: rotating 2s linear infinite;
}
.history-list {
max-height: 400px;
overflow-y: auto;
min-height: 200px;
}
.history-item {
padding: 12px 15px;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background-color 0.3s;
}
.history-item:last-child {
border-bottom: none;
}
.history-item:hover {
background-color: #f5f7fa;
}
.history-item.active {
background-color: #ecf5ff;
}
.history-item-content {
min-height: 50px;
}
.history-title {
font-size: 14px;
margin-bottom: 5px;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.history-time {
font-size: 12px;
color: #999;
}
.empty-history {
text-align: center;
color: #909399;
padding: 30px 0;
}
.icon-wrapper {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s;
}
.icon-wrapper:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.icon-wrapper+.icon-wrapper {
margin-left: 8px;
}
.system-message .message-text {
max-width: 100%;
}
</style>