From 3880399bef4144fa15264f470a0a51034c0253c9 Mon Sep 17 00:00:00 2001 From: houzhongjian <houzhongyi@126.com> Date: 星期四, 29 五月 2025 14:00:18 +0800 Subject: [PATCH] ai工业大模型代码提交 --- src/views/ai/dashboard/components/conversation/CommonConversationUpdateForm.vue | 219 + src/views/ai/dashboard/components/conversation/ConversationListEmpty.vue | 78 src/components/MarkdownView/index.vue | 5 src/assets/ai/zhuanlu/data_bg3.png | 0 src/assets/ai/zhuanlu/ldggrqsyc_title.png | 0 src/assets/ai/zhuanlu/refresh.png | 0 src/views/ai/dashboard/components/message/MessageListEmpty.vue | 66 src/assets/ai/zhuanlu/content_select.png | 0 src/components/Dialog/src/DialogDashboard.vue | 118 src/views/ai/dashboard/components/conversation/HistoryConversationList.vue | 459 +++ src/components/Dialog/src/DialogHistory.vue | 138 + src/api/ai/questiontemplate/index.js | 53 src/views/ai/dashboard/components/conversation/CommonConversation.vue | 964 +++++++ src/assets/ai/zhuanlu/delete.png | 0 src/assets/ai/zhuanlu/edit.png | 0 src/assets/ai/zhuanlu/ldghslyc_title.png | 0 src/views/ai/dashboard/components/message/HistoryMessageList.vue | 290 ++ src/views/ai/dashboard/components/conversation/CommonConversationList.vue | 526 ++++ src/views/ai/questionparamsetting/index.vue | 148 + src/assets/ai/zhuanlu/ddtljl_result_title.png | 0 src/views/ai/dashboard/components/message/MessageList.vue | 132 src/api/ai/chat/message/index.ts | 68 src/views/ai/questiontemplate/index.vue | 186 + src/assets/ai/zhuanlu/data_bg4.png | 0 src/router/modules/remaining.ts | 8 src/assets/ai/zhuanlu/icon_conversion.png | 0 src/assets/ai/zhuanlu/content.png | 0 src/assets/ai/zhuanlu/icon_bg.png | 0 src/assets/ai/zhuanlu/left_label.png | 0 src/utils/rem.ts | 9 src/views/ai/questionparamsetting/QuestionParamSettingForm.vue | 117 src/views/ai/questiontemplate/QuestionTemplateForm.vue | 148 + src/api/ai/questionparamsetting/index.js | 53 src/assets/ai/zhuanlu/common_title.png | 0 src/assets/ai/zhuanlu/copy.png | 0 /dev/null | 1138 -------- src/assets/ai/zhuanlu/assistant.png | 0 src/assets/ai/zhuanlu/user.png | 0 src/views/ai/dashboard/components/conversation/ConversationList.vue | 1 src/assets/ai/zhuanlu/conversation.png | 0 src/assets/ai/zhuanlu/right_label.png | 0 src/assets/ai/zhuanlu/conversation_big.png | 0 src/assets/ai/zhuanlu/scmbyyxzb_title.png | 0 src/views/ai/dashboard/components/message/ModelMessageList.vue | 193 + src/views/ai/dashboard/components/message/HistoryMessageDialog.vue | 375 ++ src/assets/ai/zhuanlu/mqhsjhxx_title.png | 0 src/views/ai/dashboard/zhuanlu/index.vue | 2347 +++++++++++++++++ 47 files changed, 6,607 insertions(+), 1,232 deletions(-) diff --git a/src/api/ai/chat/message/index.ts b/src/api/ai/chat/message/index.ts index 62e82b6..1123524 100644 --- a/src/api/ai/chat/message/index.ts +++ b/src/api/ai/chat/message/index.ts @@ -2,6 +2,7 @@ import { fetchEventSource } from '@microsoft/fetch-event-source' import { getAccessToken } from '@/utils/auth' import { config } from '@/config/axios/config' +import {refreshToken} from "@/api/login"; // 聊天VO export interface ChatMessageVO { @@ -13,6 +14,9 @@ model: number // 模型标志 modelId: number // 模型编号 content: string // 聊天内容 + thinking: string // 聊天思考 + thinkingFlag: boolean // 聊天思考 + conclusion: string // 聊天结论 tokens: number // 消耗 Token 数量 segmentIds?: number[] // 段落编号 segments?: { @@ -73,34 +77,34 @@ }) }, // 发送 Stream 消息 【工业大模型专用】 - sendEnergyChatMessageStream: async ( - conversationId: number, - content: string, - ctrl, - enableContext: boolean, - onMessage, - onError, - onClose - ) => { - const token = getAccessToken() - return fetchEventSource(`${config.base_url}/ai/chat/message/send-energy-stream`, { - method: 'post', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}` - }, - openWhenHidden: true, - body: JSON.stringify({ - conversationId, - content, - useContext: enableContext - }), - onmessage: onMessage, - onerror: onError, - onclose: onClose, - signal: ctrl.signal - }) - }, + // sendEnergyChatMessageStream: async ( + // conversationId: number, + // content: string, + // ctrl, + // enableContext: boolean, + // onMessage, + // onError, + // onClose + // ) => { + // const token = getAccessToken() + // return fetchEventSource(`${config.base_url}/ai/chat/message/send-energy-stream`, { + // method: 'post', + // headers: { + // 'Content-Type': 'application/json', + // Authorization: `Bearer ${token}` + // }, + // openWhenHidden: true, + // body: JSON.stringify({ + // conversationId, + // content, + // useContext: enableContext + // }), + // onmessage: onMessage, + // onerror: onError, + // onclose: onClose, + // signal: ctrl.signal + // }) + // }, // 删除消息 deleteChatMessage: async (id: string) => { @@ -114,10 +118,10 @@ }) }, - // 删除消息【工业大模型专用】 - deleteEnergyChatMessage: async (id: string) => { - return await request.delete({ url: `/ai/chat/message/delete-energy?id=${id}` }) - }, + // // 删除消息【工业大模型专用】 + // deleteEnergyChatMessage: async (id: string) => { + // return await request.delete({ url: `/ai/chat/message/delete-energy?id=${id}` }) + // }, // 删除指定对话的消息【工业大模型专用】 deleteEnergyByConversationId: async (conversationId: number) => { diff --git a/src/api/ai/questionparamsetting/index.js b/src/api/ai/questionparamsetting/index.js new file mode 100644 index 0000000..6c337b3 --- /dev/null +++ b/src/api/ai/questionparamsetting/index.js @@ -0,0 +1,53 @@ +import request from '@/utils/request' + +// 创建大模型问题设置参数 +export function createQuestionParamSetting(data) { + return request({ + url: '/ai/question-param-setting/create', + method: 'post', + data: data + }) +} + +// 更新大模型问题设置参数 +export function updateQuestionParamSetting(data) { + return request({ + url: '/ai/question-param-setting/update', + method: 'put', + data: data + }) +} + +// 删除大模型问题设置参数 +export function deleteQuestionParamSetting(id) { + return request({ + url: '/ai/question-param-setting/delete?id=' + id, + method: 'delete' + }) +} + +// 获得大模型问题设置参数 +export function getQuestionParamSetting(id) { + return request({ + url: '/ai/question-param-setting/get?id=' + id, + method: 'get' + }) +} + +// 获得大模型问题设置参数分页 +export function getQuestionParamSettingPage(params) { + return request({ + url: '/ai/question-param-setting/page', + method: 'get', + params + }) +} +// 导出大模型问题设置参数 Excel +export function exportQuestionParamSettingExcel(params) { + return request({ + url: '/ai/question-param-setting/export-excel', + method: 'get', + params, + responseType: 'blob' + }) +} \ No newline at end of file diff --git a/src/api/ai/questiontemplate/index.js b/src/api/ai/questiontemplate/index.js new file mode 100644 index 0000000..520ba3f --- /dev/null +++ b/src/api/ai/questiontemplate/index.js @@ -0,0 +1,53 @@ +import request from '@/utils/request' + +// 创建大模型问题模板 +export function createQuestionTemplate(data) { + return request({ + url: '/ai/question-template/create', + method: 'post', + data: data + }) +} + +// 更新大模型问题模板 +export function updateQuestionTemplate(data) { + return request({ + url: '/ai/question-template/update', + method: 'put', + data: data + }) +} + +// 删除大模型问题模板 +export function deleteQuestionTemplate(id) { + return request({ + url: '/ai/question-template/delete?id=' + id, + method: 'delete' + }) +} + +// 获得大模型问题模板 +export function getQuestionTemplate(id) { + return request({ + url: '/ai/question-template/get?id=' + id, + method: 'get' + }) +} + +// 获得大模型问题模板分页 +export function getQuestionTemplatePage(params) { + return request({ + url: '/ai/question-template/page', + method: 'get', + params + }) +} +// 导出大模型问题模板 Excel +export function exportQuestionTemplateExcel(params) { + return request({ + url: '/ai/question-template/export-excel', + method: 'get', + params, + responseType: 'blob' + }) +} \ No newline at end of file diff --git a/src/assets/ai/zhuanlu/assistant.png b/src/assets/ai/zhuanlu/assistant.png new file mode 100644 index 0000000..6ac16ef --- /dev/null +++ b/src/assets/ai/zhuanlu/assistant.png Binary files differ diff --git a/src/assets/ai/zhuanlu/common_title.png b/src/assets/ai/zhuanlu/common_title.png new file mode 100644 index 0000000..7670b91 --- /dev/null +++ b/src/assets/ai/zhuanlu/common_title.png Binary files differ diff --git a/src/assets/ai/zhuanlu/content.png b/src/assets/ai/zhuanlu/content.png new file mode 100644 index 0000000..52e59ce --- /dev/null +++ b/src/assets/ai/zhuanlu/content.png Binary files differ diff --git a/src/assets/ai/zhuanlu/content_select.png b/src/assets/ai/zhuanlu/content_select.png new file mode 100644 index 0000000..d6b928f --- /dev/null +++ b/src/assets/ai/zhuanlu/content_select.png Binary files differ diff --git a/src/assets/ai/zhuanlu/conversation.png b/src/assets/ai/zhuanlu/conversation.png new file mode 100644 index 0000000..48b840f --- /dev/null +++ b/src/assets/ai/zhuanlu/conversation.png Binary files differ diff --git a/src/assets/ai/zhuanlu/conversation_big.png b/src/assets/ai/zhuanlu/conversation_big.png new file mode 100644 index 0000000..b51c940 --- /dev/null +++ b/src/assets/ai/zhuanlu/conversation_big.png Binary files differ diff --git a/src/assets/ai/zhuanlu/copy.png b/src/assets/ai/zhuanlu/copy.png new file mode 100644 index 0000000..f148cba --- /dev/null +++ b/src/assets/ai/zhuanlu/copy.png Binary files differ diff --git a/src/assets/ai/zhuanlu/data_bg3.png b/src/assets/ai/zhuanlu/data_bg3.png new file mode 100644 index 0000000..bdcbe84 --- /dev/null +++ b/src/assets/ai/zhuanlu/data_bg3.png Binary files differ diff --git a/src/assets/ai/zhuanlu/data_bg4.png b/src/assets/ai/zhuanlu/data_bg4.png new file mode 100644 index 0000000..1f5f878 --- /dev/null +++ b/src/assets/ai/zhuanlu/data_bg4.png Binary files differ diff --git a/src/assets/ai/zhuanlu/ddtljl_result_title.png b/src/assets/ai/zhuanlu/ddtljl_result_title.png new file mode 100644 index 0000000..2162562 --- /dev/null +++ b/src/assets/ai/zhuanlu/ddtljl_result_title.png Binary files differ diff --git a/src/assets/ai/zhuanlu/delete.png b/src/assets/ai/zhuanlu/delete.png new file mode 100644 index 0000000..18f3848 --- /dev/null +++ b/src/assets/ai/zhuanlu/delete.png Binary files differ diff --git a/src/assets/ai/zhuanlu/edit.png b/src/assets/ai/zhuanlu/edit.png new file mode 100644 index 0000000..10254bb --- /dev/null +++ b/src/assets/ai/zhuanlu/edit.png Binary files differ diff --git a/src/assets/ai/zhuanlu/icon_bg.png b/src/assets/ai/zhuanlu/icon_bg.png new file mode 100644 index 0000000..01c8235 --- /dev/null +++ b/src/assets/ai/zhuanlu/icon_bg.png Binary files differ diff --git a/src/assets/ai/zhuanlu/icon_conversion.png b/src/assets/ai/zhuanlu/icon_conversion.png new file mode 100644 index 0000000..48b840f --- /dev/null +++ b/src/assets/ai/zhuanlu/icon_conversion.png Binary files differ diff --git a/src/assets/ai/zhuanlu/ldggrqsyc_title.png b/src/assets/ai/zhuanlu/ldggrqsyc_title.png new file mode 100644 index 0000000..4619d97 --- /dev/null +++ b/src/assets/ai/zhuanlu/ldggrqsyc_title.png Binary files differ diff --git a/src/assets/ai/zhuanlu/ldghslyc_title.png b/src/assets/ai/zhuanlu/ldghslyc_title.png new file mode 100644 index 0000000..d561e77 --- /dev/null +++ b/src/assets/ai/zhuanlu/ldghslyc_title.png Binary files differ diff --git a/src/assets/ai/zhuanlu/left_label.png b/src/assets/ai/zhuanlu/left_label.png new file mode 100644 index 0000000..2807509 --- /dev/null +++ b/src/assets/ai/zhuanlu/left_label.png Binary files differ diff --git a/src/assets/ai/zhuanlu/mqhsjhxx_title.png b/src/assets/ai/zhuanlu/mqhsjhxx_title.png new file mode 100644 index 0000000..b5f2526 --- /dev/null +++ b/src/assets/ai/zhuanlu/mqhsjhxx_title.png Binary files differ diff --git a/src/assets/ai/zhuanlu/refresh.png b/src/assets/ai/zhuanlu/refresh.png new file mode 100644 index 0000000..96e2c9b --- /dev/null +++ b/src/assets/ai/zhuanlu/refresh.png Binary files differ diff --git a/src/assets/ai/zhuanlu/right_label.png b/src/assets/ai/zhuanlu/right_label.png new file mode 100644 index 0000000..a74da5b --- /dev/null +++ b/src/assets/ai/zhuanlu/right_label.png Binary files differ diff --git a/src/assets/ai/zhuanlu/scmbyyxzb_title.png b/src/assets/ai/zhuanlu/scmbyyxzb_title.png new file mode 100644 index 0000000..912ee9b --- /dev/null +++ b/src/assets/ai/zhuanlu/scmbyyxzb_title.png Binary files differ diff --git a/src/assets/ai/zhuanlu/user.png b/src/assets/ai/zhuanlu/user.png new file mode 100644 index 0000000..e55f54f --- /dev/null +++ b/src/assets/ai/zhuanlu/user.png Binary files differ diff --git a/src/components/Dialog/src/DialogDashboard.vue b/src/components/Dialog/src/DialogDashboard.vue new file mode 100644 index 0000000..358931b --- /dev/null +++ b/src/components/Dialog/src/DialogDashboard.vue @@ -0,0 +1,118 @@ +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' +import { isNumber } from '@/utils/is' +defineOptions({ name: 'DialogDashboard' }) + +const slots = useSlots() + +const props = defineProps({ + modelValue: propTypes.bool.def(false), + title: propTypes.string.def('Dialog'), + fullscreen: propTypes.bool.def(true), + width: propTypes.oneOfType([String, Number]).def('30%'), + scroll: propTypes.bool.def(false), // 是否开启滚动条。如果是的话,按照 maxHeight 设置最大高度 + maxHeight: propTypes.oneOfType([String, Number]).def('400px') +}) + +const getBindValue = computed(() => { + const delArr: string[] = ['fullscreen', 'title', 'maxHeight', 'appendToBody'] + const attrs = useAttrs() + const obj = { ...attrs, ...props } + for (const key in obj) { + if (delArr.indexOf(key) !== -1) { + delete obj[key] + } + } + return obj +}) + +</script> + +<template> + <ElDialog + v-bind="getBindValue" + :close-on-click-modal="true" + :width="width" + destroy-on-close + lock-scroll + draggable + class="dashboard-dialog" + :show-close="false" + > + <template #header="{ close }"> + <div class="relative h-30px flex items-center justify-between pl-15px pr-15px"> + <slot name="title"> + {{ title }} + </slot> + <div + class="absolute right-15px top-[50%] h-40px flex translate-y-[-50%] items-center justify-between" + > + <Icon + class="is-hover cursor-pointer" + icon="ep:close" + hover-color="var(--el-color-primary)" + color="#73C4FF" + @click="close" + /> + </div> + </div> + </template> + + <slot></slot> + <template v-if="slots.footer" #footer> + <slot name="footer"></slot> + </template> + </ElDialog> +</template> + +<style lang="scss"> +.el-dialog.is-draggable .el-dialog__header { + color: white !important; +} +::v-deep .el-input { + background: rgba(0,194,255,0.08) !important; +} +::v-deep .el-input__inner { + color: #8FD6FE; + background: rgba(0,194,255,0.08) !important; + border: 1px solid #1D9FE8 +} +.dashboard-dialog { + color: #73C4FF; + background: rgba(3,29,76,0.79); + border-radius: 4px 4px 4px 4px; + border: 1px solid; + .#{$elNamespace}-overlay-dialog { + display: flex; + justify-content: center; + align-items: center; + } + + .#{$elNamespace}-dialog { + margin: 0 !important; + + &__header { + height: 40px; + padding: 0; + margin-right: 0 !important; + margin-bottom: 20px; + background: + url("@/assets/ai/zhuanlu/common_title.png") no-repeat, + linear-gradient(to bottom, #0a1633dd, #0a1633dd); /* 叠加深色遮罩 */ + div { + color: #73C4FF; + margin-left: 20px; + } + } + + &__body { + padding: 15px !important; + } + + &__headerbtn { + color: #73C4FF; + top: 0; + } + } +} +</style> diff --git a/src/components/Dialog/src/DialogHistory.vue b/src/components/Dialog/src/DialogHistory.vue new file mode 100644 index 0000000..0b3dd5a --- /dev/null +++ b/src/components/Dialog/src/DialogHistory.vue @@ -0,0 +1,138 @@ +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' +import { isNumber } from '@/utils/is' +defineOptions({ name: 'DialogHistory' }) + +const slots = useSlots() + +const props = defineProps({ + modelValue: propTypes.bool.def(false), + title: propTypes.string.def('Dialog'), + fullscreen: propTypes.bool.def(true), + width: propTypes.oneOfType([String, Number]).def('30%'), + scroll: propTypes.bool.def(false), // 是否开启滚动条。如果是的话,按照 maxHeight 设置最大高度 + maxHeight: propTypes.oneOfType([String, Number]).def('400px') +}) + +const getBindValue = computed(() => { + const delArr: string[] = ['fullscreen', 'title', 'maxHeight', 'appendToBody'] + const attrs = useAttrs() + const obj = { ...attrs, ...props } + for (const key in obj) { + if (delArr.indexOf(key) !== -1) { + delete obj[key] + } + } + return obj +}) + +const isFullscreen = ref(false) + +const toggleFull = () => { + isFullscreen.value = !unref(isFullscreen) +} + +const dialogHeight = ref(isNumber(props.maxHeight) ? `${props.maxHeight}px` : props.maxHeight) + +watch( + () => isFullscreen.value, + async (val: boolean) => { + await nextTick() + if (val) { + const windowHeight = document.documentElement.offsetHeight + dialogHeight.value = `${windowHeight - 55 - 60 - (slots.footer ? 63 : 0)}px` + } else { + dialogHeight.value = isNumber(props.maxHeight) ? `${props.maxHeight}px` : props.maxHeight + } + }, + { + immediate: true + } +) + +</script> + +<template> + <ElDialog + v-bind="getBindValue" + :fullscreen="isFullscreen" + :close-on-click-modal="true" + :width="width" + destroy-on-close + lock-scroll + draggable + class="history-dialog" + :show-close="false" + > + <template #header="{ close }"> + <div class="relative h-30px flex items-center justify-between pl-15px pr-15px"> + <slot name="title"> + {{ title }} + </slot> + <div + class="absolute right-15px top-[50%] h-40px flex translate-y-[-50%] items-center justify-between" + > + <Icon + v-if="fullscreen" + class="is-hover mr-10px cursor-pointer" + :icon="isFullscreen ? 'radix-icons:exit-full-screen' : 'radix-icons:enter-full-screen'" + color="#73C4FF" + hover-color="var(--el-color-primary)" + @click="toggleFull" + /> + <Icon + class="is-hover cursor-pointer" + icon="ep:close" + hover-color="var(--el-color-primary)" + color="#73C4FF" + @click="close" + /> + </div> + </div> + </template> + + <slot></slot> + <template v-if="slots.footer" #footer> + <slot name="footer"></slot> + </template> + </ElDialog> +</template> + +<style lang="scss"> +.history-dialog { + height: 90vh; + margin-top: 30px; + color: #73C4FF; + overflow: hidden; /* 防止内容溢出 */ + background: rgba(3,29,76,0.79); + border-radius: 4px 4px 4px 4px; + border: 1px solid; + .#{$elNamespace}-overlay-dialog { + display: flex; + justify-content: center; + align-items: center; + } + + .#{$elNamespace}-dialog { + margin: 0 !important; + + &__header { + height: 40px; + padding: 0; + margin-right: 0 !important; + background: + url("@/assets/ai/zhuanlu/common_title.png") left no-repeat, + linear-gradient(to bottom, #0a1633dd, #0a1633dd); /* 叠加深色遮罩 */ + div { + color: #73C4FF; + margin-left: 20px; + } + } + + &__headerbtn { + color: #73C4FF; + top: 0; + } + } +} +</style> diff --git a/src/components/MarkdownView/index.vue b/src/components/MarkdownView/index.vue index 0b1837f..65541f6 100644 --- a/src/components/MarkdownView/index.vue +++ b/src/components/MarkdownView/index.vue @@ -39,7 +39,10 @@ /** 保留换行符 */ const formatContent = (text) => { - return text.replace(/\n/g, '<br>') + if (text) { + return text.replace(/\n/g, '<br>') + } + return text } /** 初始化 **/ diff --git a/src/router/modules/remaining.ts b/src/router/modules/remaining.ts index 8e594fd..2e808c4 100644 --- a/src/router/modules/remaining.ts +++ b/src/router/modules/remaining.ts @@ -1,6 +1,8 @@ import {Layout} from '@/utils/routerHelper' +import {meta} from "eslint-plugin-prettier"; const { t } = useI18n() + /** * redirect: noredirect 当设置 noredirect 的时候该路由在面包屑导航中不可被点击 * name:'router-name' 设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题 @@ -576,11 +578,11 @@ { path: '/ai/zhuanlu', name: 'Zhuanlu', - component: () => import('@/views/ai/dashboard/zhuanlu/Index.vue'), + component: () => import('@/views/ai/dashboard/zhuanlu/index.vue'), meta: { hidden: true, - noTagsView: true - }, + noTagsView: true, + } }, { path: '/model/analysis', diff --git a/src/utils/rem.ts b/src/utils/rem.ts new file mode 100644 index 0000000..00be2a1 --- /dev/null +++ b/src/utils/rem.ts @@ -0,0 +1,9 @@ +// utils/rem.js +const baseWidth = 1920 // 设计稿基准宽度 +const baseFontSize = 16 // 基准字体大小 + +export const setRem = () => { + const scale = document.documentElement.clientWidth / baseWidth + document.documentElement.style.fontSize = + `${Math.min(scale, 1.5) * baseFontSize}px` // 限制最大缩放比例 +} diff --git a/src/views/ai/dashboard/components/conversation/CommonConversation.vue b/src/views/ai/dashboard/components/conversation/CommonConversation.vue new file mode 100644 index 0000000..2de4e77 --- /dev/null +++ b/src/views/ai/dashboard/components/conversation/CommonConversation.vue @@ -0,0 +1,964 @@ +<template> + <!-- 整体容器添加相对定位 --> + <div class="container-wrapper" ref="containerRef"> + <!-- 折叠按钮 --> + <div + class="sidebar-toggle" + :style="{ left: toggleLeft }" + @click.stop="toggleSidebar" + ref="toggleRef"> + <Icon :icon="isCollapsed ? 'ep:caret-right' : 'ep:caret-left'" /> + </div> + + <!-- 左侧对话列表 --> + <div + class="conversation-wrapper" + :class="{ collapsed: isCollapsed }" + :style="{ width: sidebarWidth }" + ref="sidebarRef"> + <ConversationList + :active-id="activeConversationId" + ref="conversationListRef" + @on-conversation-create="handleConversationCreateSuccess" + @on-conversation-click="handleConversationClick" + @on-conversation-clear="handleConversationClear" + @on-conversation-delete="handlerConversationDelete" + /> + </div> + <!-- 右侧:对话详情 --> + <el-container class="detail-container"> + <el-header class="header"> + <div class="title"> + {{ activeConversation?.title ? activeConversation?.title : '对话' }} + <span v-if="activeMessageList.length">({{ activeMessageList.length }})</span> + </div> + <div class="btns" v-if="activeConversation"> + <el-button type="primary" bg plain size="small" @click="openChatConversationUpdateForm"> + <span v-html="activeConversation?.modelName"></span> + <Icon icon="ep:setting" class="ml-10px" /> + </el-button> + <el-button size="small" class="btn" @click="handlerMessageClear"> + <Icon icon="heroicons-outline:archive-box-x-mark" color="#73C4FF" /> + </el-button> + <el-button size="small" class="btn" @click="handleGoBottomMessage"> + <Icon icon="ep:download" color="#73C4FF" /> + </el-button> + <el-button size="small" class="btn" @click="handleGoTopMessage"> + <Icon icon="ep:top" color="#73C4FF" /> + </el-button> + </div> + </el-header> + + <!-- main:消息列表 --> + <el-main class="main-container"> + <div class="message-container"> + <!-- 情况一:消息加载中 --> + <MessageLoading v-if="activeMessageListLoading" /> + <!-- 情况二:无聊天对话时 --> + <MessageNewConversation + v-if="!activeConversation" + @on-new-conversation="handleConversationCreate" + /> + <!-- 情况三:消息列表为空 --> + <MessageListEmpty + v-if="!activeMessageListLoading && messageList.length === 0 && activeConversation" + @on-prompt="doSendMessage" + /> + <!-- 情况四:消息列表不为空 --> + <MessageList + v-if="!activeMessageListLoading && messageList.length > 0" + ref="messageRef" + :conversation="activeConversation" + :list="messageList" + @on-delete-success="handleMessageDelete" + @on-edit="handleMessageEdit" + @on-refresh="handleMessageRefresh" + /> + </div> + </el-main> + + <!-- 底部 --> + <el-footer class="footer-container"> + <!-- 输入框 --> + <div class="input-container"> + <form class="prompt-from"> + <textarea + class="prompt-input" + v-model="prompt" + @keydown="handleSendByKeydown" + @input="handlePromptInput" + @compositionstart="onCompositionstart" + @compositionend="onCompositionend" + placeholder="问我任何问题...(Shift+Enter 换行,按下 Enter 发送)" + ></textarea> + <div class="prompt-btns"> + <div class="content"> + <el-button + :class="{ 'active-button': enableContext }" + @click="enableContext = !enableContext" + > + <el-icon class="content-icon" /> + 上下文 + </el-button> + </div> + <div class="message"> + <el-button + type="primary" + size="default" + @click="handleSendByButton" + :loading="conversationInProgress" + v-if="conversationInProgress == false" + > + {{ conversationInProgress ? '进行中' : '发消息' }} + </el-button> + <el-button + type="danger" + size="default" + @click="stopStream()" + v-if="conversationInProgress == true" + > + 停止 + </el-button> + </div> + </div> + </form> + </div> + </el-footer> + </el-container> + </div> + <!-- 更新对话 Form --> + <ConversationUpdateForm + ref="conversationUpdateFormRef" + @success="handleConversationUpdateSuccess" + /> +</template> + +<script setup lang="ts"> +import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message' +import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation' +import ConversationList from './CommonConversationList.vue' +import ConversationUpdateForm from './CommonConversationUpdateForm.vue' +import MessageList from '../message/MessageList.vue' +import MessageListEmpty from '../message/MessageListEmpty.vue' +import MessageLoading from '../message/MessageLoading.vue' +import MessageNewConversation from '../message/MessageNewConversation.vue' +import { onClickOutside } from '@vueuse/core' +import * as authUtil from "@/utils/auth"; +import {refreshToken} from "@/api/login"; + +/** AI 聊天对话 列表 */ +defineOptions({ name: 'NormalConversation' }) + +const route = useRoute() // 路由 +const message = useMessage() // 消息弹窗 + +const isCollapsed = ref(true) +const sidebarWidth = ref('270px') +const toggleLeft = computed(() => isCollapsed.value ? '0' : sidebarWidth.value) + +// 新增DOM引用用于会话列表的展开和收缩 +const containerRef = ref<HTMLElement>() +const sidebarRef = ref<HTMLElement>() +const toggleRef = ref<HTMLElement>() + +// 点击外部区域处理 +onClickOutside(sidebarRef, (event) => { + if (!isCollapsed.value && + !sidebarRef.value?.contains(event.target) && + !toggleRef.value?.contains(event.target)) { + isCollapsed.value = true + } +}) + +// 切换侧边栏 +const toggleSidebar = () => { + isCollapsed.value = !isCollapsed.value +} + +// 聊天对话 +const conversationListRef = ref() +const activeConversationId = ref<number | null>(null) // 选中的对话编号 +const activeConversation = ref<ChatConversationVO | null>(null) // 选中的 Conversation +const conversationInProgress = ref(false) // 对话是否正在进行中。目前只有【发送】消息时,会更新为 true,避免切换对话、删除对话等操作 + +// 消息列表 +const messageRef = ref() +const activeMessageList = ref<ChatMessageVO[]>([]) // 选中对话的消息列表 +const activeMessageListLoading = ref<boolean>(false) // activeMessageList 是否正在加载中 +const activeMessageListLoadingTimer = ref<any>() // activeMessageListLoading Timer 定时器。如果加载速度很快,就不进入加载中 +// 消息滚动 +const textSpeed = ref<number>(50) // Typing speed in milliseconds +const textRoleRunning = ref<boolean>(false) // Typing speed in milliseconds + +// 发送消息输入框 +const isComposing = ref(false) // 判断用户是否在输入 +const conversationInAbortController = ref<any>() // 对话进行中 abort 控制器(控制 stream 对话) +const inputTimeout = ref<any>() // 处理输入中回车的定时器 +const prompt = ref<string>() // prompt +const enableContext = ref<boolean>(true) // 是否开启上下文 +// 接收 Stream 消息 +const receiveMessageFullText = ref('') +const receiveMessageDisplayedText = ref('') + + +// =========== 【聊天对话】相关 =========== + +/** 获取对话信息 */ +const getConversation = async (id: number | null) => { + if (!id) { + return + } + const conversation: ChatConversationVO = await ChatConversationApi.getChatConversationMy(id) + if (!conversation) { + return + } + activeConversation.value = conversation + activeConversationId.value = conversation.id +} + +/** + * 点击某个对话 + * + * @param conversation 选中的对话 + * @return 是否切换成功 + */ +const handleConversationClick = async (conversation: ChatConversationVO) => { + // 对话进行中,不允许切换 + if (conversationInProgress.value) { + message.alert('对话中,不允许切换!') + return false + } + + // 更新选中的对话 id + activeConversationId.value = conversation.id + activeConversation.value = conversation + // 刷新 message 列表 + await getMessageList() + // 滚动底部 + scrollToBottom(true) + // 清空输入框 + prompt.value = '' + return true +} + +/** 删除某个对话*/ +const handlerConversationDelete = async (delConversation: ChatConversationVO) => { + // 删除的对话如果是当前选中的,那么就重置 + if (activeConversationId.value === delConversation.id) { + await handleConversationClear() + } +} +/** 清空选中的对话 */ +const handleConversationClear = async () => { + // 对话进行中,不允许切换 + if (conversationInProgress.value) { + message.alert('对话中,不允许切换!') + return false + } + activeConversationId.value = null + activeConversation.value = null + activeMessageList.value = [] +} + +/** 修改聊天对话 */ +const conversationUpdateFormRef = ref() +const openChatConversationUpdateForm = async () => { + conversationUpdateFormRef.value.open(activeConversationId.value) +} +const handleConversationUpdateSuccess = async () => { + // 对话更新成功,刷新最新信息 + await getConversation(activeConversationId.value) +} + +/** 处理聊天对话的创建成功 */ +const handleConversationCreate = async () => { + // 创建对话 + await conversationListRef.value.createConversation() +} +/** 处理聊天对话的创建成功 */ +const handleConversationCreateSuccess = async () => { + // 创建新的对话,清空输入框 + prompt.value = '' +} + +// =========== 【消息列表】相关 =========== + +/** 获取消息 message 列表 */ +const getMessageList = async () => { + try { + if (activeConversationId.value === null) { + return + } + // Timer 定时器,如果加载速度很快,就不进入加载中 + activeMessageListLoadingTimer.value = setTimeout(() => { + activeMessageListLoading.value = true + }, 60) + + // 获取消息列表 + activeMessageList.value = await ChatMessageApi.getChatMessageListByConversationId( + activeConversationId.value + ) + + // 滚动到最下面 + await nextTick() + await scrollToBottom() + } finally { + // time 定时器,如果加载速度很快,就不进入加载中 + if (activeMessageListLoadingTimer.value) { + clearTimeout(activeMessageListLoadingTimer.value) + } + // 加载结束 + activeMessageListLoading.value = false + } +} + +/** + * 消息列表 + * + * 和 {@link #getMessageList()} 的差异是,把 systemMessage 考虑进去 + */ +const messageList = computed(() => { + if (activeMessageList.value.length > 0) { + dealResult(activeMessageList.value) + return activeMessageList.value + } + // 没有消息时,如果有 systemMessage 则展示它 + if (activeConversation.value?.systemMessage) { + return [ + { + id: 0, + type: 'system', + content: activeConversation.value.systemMessage + } + ] + } + return [] +}) + +//处理调度推理结论 +const dealResult = (conversations: any) => { + const regex = /<think>(\n*)([\s\S]*?)(\n*)<\/think>(\n*)([\s\S]*)/; + conversations.forEach((conversation) => { + if(conversation.content.includes('<\/think>')) { + conversation.thinkingFlag = false + } else { + conversation.thinkingFlag = true + } + const match = conversation.content.match(regex); + if(match) { + conversation.thinking = match[2]; + conversation.conclusion = match[5] + } + }) +} + +/** 处理删除 message 消息 */ +const handleMessageDelete = () => { + if (conversationInProgress.value) { + message.alert('回答中,不能删除!') + return + } + // 刷新 message 列表 + getMessageList() +} + +/** 处理 message 清空 */ +const handlerMessageClear = async () => { + if (!activeConversationId.value) { + return + } + try { + // 确认提示 + await message.delConfirm('确认清空对话消息?') + // 清空对话 + await ChatMessageApi.deleteByConversationId(activeConversationId.value) + // 刷新 message 列表 + activeMessageList.value = [] + } catch {} +} + +/** 回到 message 列表的顶部 */ +const handleGoTopMessage = () => { + messageRef.value.handlerGoTop() +} + +/** 回到 message 列表的底部 */ +const handleGoBottomMessage = () => { + messageRef.value.handleGoBottom() +} + +// =========== 【发送消息】相关 =========== + +/** 处理来自 keydown 的发送消息 */ +const handleSendByKeydown = async (event) => { + // 判断用户是否在输入 + if (isComposing.value) { + return + } + // 进行中不允许发送 + if (conversationInProgress.value) { + return + } + const content = prompt.value?.trim() as string + if (event.key === 'Enter') { + if (event.shiftKey) { + // 插入换行 + prompt.value += '\r\n' + event.preventDefault() // 防止默认的换行行为 + } else { + // 发送消息 + await doSendMessage(content) + event.preventDefault() // 防止默认的提交行为 + } + } +} + +/** 处理来自【发送】按钮的发送消息 */ +const handleSendByButton = () => { + doSendMessage(prompt.value?.trim() as string) +} + +/** 处理 prompt 输入变化 */ +const handlePromptInput = (event) => { + // 非输入法 输入设置为 true + if (!isComposing.value) { + // 回车 event data 是 null + if (event.data == null) { + return + } + isComposing.value = true + } + // 清理定时器 + if (inputTimeout.value) { + clearTimeout(inputTimeout.value) + } + // 重置定时器 + inputTimeout.value = setTimeout(() => { + isComposing.value = false + }, 400) +} +// TODO @芋艿:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑 +const onCompositionstart = () => { + isComposing.value = true +} +const onCompositionend = () => { + // console.log('输入结束...') + setTimeout(() => { + isComposing.value = false + }, 200) +} + +/** 真正执行【发送】消息操作 */ +const doSendMessage = async (content: string) => { + // 校验 + if (content.length < 1) { + message.error('发送失败,原因:内容为空!') + return + } + if (activeConversationId.value == null) { + message.error('还没创建对话,不能发送!') + return + } + // 清空输入框 + prompt.value = '' + // 发送请求时如果accessToken过期,无法中断请求,暂时增加请求前刷新token + authUtil.setToken(await refreshToken()) + // 执行发送 + await doSendMessageStream({ + conversationId: activeConversationId.value, + content: content + } as ChatMessageVO) +} + +/** 真正执行【发送】消息操作 */ +const doSendMessageStream = async (userMessage: ChatMessageVO) => { + // 创建 AbortController 实例,以便中止请求 + conversationInAbortController.value = new AbortController() + // 标记对话进行中 + conversationInProgress.value = true + // 设置为空 + receiveMessageFullText.value = '' + + try { + // 1.1 先添加两个假数据,等 stream 返回再替换 + activeMessageList.value.push({ + id: -1, + conversationId: activeConversationId.value, + type: 'user', + content: userMessage.content, + createTime: new Date() + } as ChatMessageVO) + activeMessageList.value.push({ + id: -2, + conversationId: activeConversationId.value, + type: 'assistant', + content: '思考中...', + createTime: new Date() + } as ChatMessageVO) + // 1.2 滚动到最下面 + await nextTick() + await scrollToBottom() // 底部 + // 1.3 开始滚动 + textRoll() + + // 2. 发送 event stream + let isFirstChunk = true // 是否是第一个 chunk 消息段 + await ChatMessageApi.sendChatMessageStream( + userMessage.conversationId, + userMessage.content, + conversationInAbortController.value, + enableContext.value, + async (res) => { + const { code, data, msg } = JSON.parse(res.data) + if (code !== 0) { + message.alert(`对话异常! ${msg}`) + return + } + + // 如果内容为空,就不处理。 + if (data.receive.content === '') { + return + } + // 首次返回需要添加一个 message 到页面,后面的都是更新 + if (isFirstChunk) { + isFirstChunk = false + // 弹出两个假数据 + activeMessageList.value.pop() + activeMessageList.value.pop() + // 更新返回的数据 + activeMessageList.value.push(data.send) + activeMessageList.value.push(data.receive) + } + // debugger + receiveMessageFullText.value = receiveMessageFullText.value + data.receive.content + // 滚动到最下面 + await scrollToBottom() + }, + (error) => { + message.alert(`对话异常! ${error}`) + stopStream() + }, + () => { + stopStream() + } + ) + } catch {} +} + +/** 停止 stream 流式调用 */ +const stopStream = async () => { + // tip:如果 stream 进行中的 message,就需要调用 controller 结束 + if (conversationInAbortController.value) { + conversationInAbortController.value.abort() + } + // 设置为 false + conversationInProgress.value = false +} + +/** 编辑 message:设置为 prompt,可以再次编辑 */ +const handleMessageEdit = (message: ChatMessageVO) => { + prompt.value = message.content +} + +/** 刷新 message:基于指定消息,再次发起对话 */ +const handleMessageRefresh = (message: ChatMessageVO) => { + doSendMessage(message.content) +} + +// ============== 【消息滚动】相关 ============= + +/** 滚动到 message 底部 */ +const scrollToBottom = async (isIgnore?: boolean) => { + await nextTick() + if (messageRef.value) { + messageRef.value.scrollToBottom(isIgnore) + } +} + +/** 自提滚动效果 */ +const textRoll = async () => { + let index = 0 + try { + // 只能执行一次 + if (textRoleRunning.value) { + return + } + // 设置状态 + textRoleRunning.value = true + receiveMessageDisplayedText.value = '' + const task = async () => { + // 调整速度 + const diff = + (receiveMessageFullText.value.length - receiveMessageDisplayedText.value.length) / 10 + if (diff > 5) { + textSpeed.value = 10 + } else if (diff > 2) { + textSpeed.value = 30 + } else if (diff > 1.5) { + textSpeed.value = 50 + } else { + textSpeed.value = 100 + } + // 对话结束,就按 30 的速度 + if (!conversationInProgress.value) { + textSpeed.value = 10 + } + + if (index < receiveMessageFullText.value.length) { + receiveMessageDisplayedText.value += receiveMessageFullText.value[index] + index++ + + // 更新 message + const lastMessage = activeMessageList.value[activeMessageList.value.length - 1] + lastMessage.content = receiveMessageDisplayedText.value + // 滚动到住下面 + await scrollToBottom() + // 重新设置任务 + timer = setTimeout(task, textSpeed.value) + } else { + // 不是对话中可以结束 + if (!conversationInProgress.value) { + textRoleRunning.value = false + clearTimeout(timer) + } else { + // 重新设置任务 + timer = setTimeout(task, textSpeed.value) + } + } + } + let timer = setTimeout(task, textSpeed.value) + } catch {} +} + +/** 初始化 **/ +onMounted(async () => { + // 如果有 conversationId 参数,则默认选中 + if (route.query.conversationId) { + const id = route.query.conversationId as unknown as number + activeConversationId.value = id + await getConversation(id) + } + + // 获取列表数据 + activeMessageListLoading.value = true + await getMessageList() +}) +</script> + +<style lang="scss" scoped> + +.container-wrapper { + position: relative; // 关键定位容器 + width: 100%; + height: 100%; +} + +.sidebar-toggle { + position: absolute; + left: 300px; // 初始展开位置 + top: 40%; + z-index: 1000; + width: 20px; + height: 80px; + background: rgba(115, 196, 255, 0.5); + border-radius: 0 8px 8px 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; + color: rgba(255,215,0); + transition: left 0.3s ease, background 0.2s ease; + + &:hover { + background: #409EFF; + left: 304px; // 悬停微调 + transition: left 0.3s ease, background 0.2s ease; + } +} + +.conversation-wrapper { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 300px; + background: rgba(13,28,58,0.9); + box-shadow: 2px 0 8px rgba(0,0,0,0.1); + transition: transform 0.3s ease, opacity 0.2s ease; + z-index: 999; + overflow: hidden; + + &.collapsed { + transform: translateX(-100%); + opacity: 0; + pointer-events: none; + } +} + +// 头部 +.detail-container { + width: 100%; + height: 885px; + margin-left: 5px; + background-color: rgba(0, 0, 0, 0); /* 透明背景 */ + transition: margin 0.3s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 1; + .header { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + box-shadow: 0 0 0 0 #dcdfe6; + + .title { + font-size: 18px; + font-weight: bold; + color: gold; + } + + .btns { + display: flex; + width: 300px; + flex-direction: row; + justify-content: flex-end; + + .btn { + padding: 10px; + } + + /* 所有状态通用透明背景 */ + :deep(.el-button) { + background: transparent !important; + border-color: currentColor; /* 保持与文字同色 */ + color: #409EFF; /* 蓝色文字 */ + } + + /* 悬停状态 */ + :deep(.el-button:hover) { + background: rgba(0, 0, 0, 0.05) !important; /* 轻微悬停反馈 */ + } + + /* 点击状态 */ + :deep(.el-button:active) { + background: rgba(0, 0, 0, 0.1) !important; + } + + /* 禁用状态 */ + :deep(.el-button.is-disabled) { + opacity: 0.6; + background: transparent !important; + } + } + } + + &[style*="0"] { + margin-left: 0 !important; + } +} + +// main 容器 +.main-container { + margin-left: 10px; + padding: 0; + position: relative; + height: 500px; + width: 100%; + + .message-container { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + overflow-y: hidden; + padding: 0; + margin: 0; + /* Firefox */ + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.15) transparent; + + /* WebKit */ + &::-webkit-scrollbar { + width: 6px; + background: transparent; + } + &::-webkit-scrollbar-thumb { + border-radius: 4px; + background: rgba(0, 0, 0, 0.15); + transition: background 0.3s; + &:hover { background: rgba(0, 0, 0, 0.25); } + } + } +} + +// 底部 +.footer-container { + display: flex; + flex-direction: column; + height: 114px; + margin-left: 10px; + padding: 0; + + // 输入框 + .input-container { + display: flex; + flex-direction: column; + height: auto; + width: 876px; + margin: 0; + padding: 0; + overflow-y: auto; /* 垂直方向溢出时显示滚动条 */ + overflow-x: hidden; /* 水平方向隐藏滚动条 */ + /* Firefox */ + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.15) transparent; + + /* WebKit */ + &::-webkit-scrollbar { + width: 6px; + background: transparent; + } + &::-webkit-scrollbar-thumb { + border-radius: 4px; + background: rgba(0, 0, 0, 0.15); + transition: background 0.3s; + &:hover { background: rgba(0, 0, 0, 0.25); } + } + + .prompt-from { + display: flex; + flex-direction: column; + padding: 9px 10px; + width: 876px; + height: 114px; + background: rgba(115,196,255,0.05); + border-radius: 4px 4px 4px 4px; + border: 1px solid #73C4FF; + } + + textarea::placeholder { + color: #DBEEFF; + } + + .prompt-input { + width: 876px; + height: 113.55px; + font-weight: 400; + font-size: 14px; + background-color: rgba(219,238,255,0); + line-height: 21px; + text-align: left; + font-style: normal; + text-transform: none; + border: 0; + color: #73C4FF; + } + + .prompt-input:focus { + outline: none; + } + + .prompt-btns { + display: flex; + justify-content: space-between; + padding-bottom: 0; + padding-top: 5px; + + .content { + /* 默认状态 */ + .el-button { + background: transparent !important; + border-color: rgba(115, 196, 255, 0.5); + color: #73C4FF; + border-radius: 15px !important; + } + + /* 上下文图标处理 */ + .content-icon { + color: blue; /* 图标颜色 */ + font-size: 18px; + margin-right: 10px; + background: url("@/assets/ai/zhuanlu/content.png"); + vertical-align: middle; + } + + /* 选中状态 */ + .active-button { + background: #409eff !important; + border-color: #409eff !important; + color: white !important; + .content-icon { + background: url("@/assets/ai/zhuanlu/content_select.png"); + vertical-align: middle; + } + } + + /* 按钮组间距处理 */ + .button-group .el-button { + margin-left: 0; + border-radius: 4px; + } + + /* 悬停效果 */ + .el-button:not(.active-button):hover { + border-color: rgba(115,196,255,0.5); + color: #409eff; + } + } + .message { + /* 所有状态通用透明背景 */ + :deep(.el-button) { + background: rgba(73, 254, 210, 0.8) !important; + border-color: currentColor; /* 保持与文字同色 */ + font-family: Alimama ShuHeiTi, Alimama ShuHeiTi; + font-weight: bold; + font-size: 16px; + color: #123C4E; + clip-path: polygon( + 0 0, + 100% 0, + 100% 100%, + 10px 100%, /* 右下方向留出10px */ + 0 calc(100% - 10px) /* 左上方向留出10px */ + ); + position: relative; + padding-left: 15px; /* 增加右侧留白 */ + } + + /* 悬停状态 */ + :deep(.el-button:hover) { + background: rgba(73, 254, 210, 0.6) !important; /* 轻微悬停反馈 */ + } + + /* 点击状态 */ + :deep(.el-button:active) { + background: rgba(73, 254, 210, 1) !important; + } + + /* 禁用状态 */ + :deep(.el-button.is-disabled) { + opacity: 0.6; + background: transparent !important; + } + + /* 核心样式覆盖 */ + :deep(.el-switch__core) { + background: transparent !important; + border-radius: 0 0 15px 0 !important; + border: none !important; + height: 40px !important; + } + + /* 按钮内容容器 */ + .button-content { + display: flex; + align-items: center; + padding: 0 15px; + height: 100%; + } + } + } + } +} +</style> diff --git a/src/views/ai/dashboard/components/conversation/CommonConversationList.vue b/src/views/ai/dashboard/components/conversation/CommonConversationList.vue new file mode 100644 index 0000000..df1c641 --- /dev/null +++ b/src/views/ai/dashboard/components/conversation/CommonConversationList.vue @@ -0,0 +1,526 @@ +<!-- AI 对话 --> +<template> + <el-aside width="260px" class="conversation-container h-100%"> + <!-- 左顶部:对话 --> + <div class="h-100%"> + <div class="conversation-title"> + <img + src="@/assets/ai/zhuanlu/conversation_big.png" + class="mr-3px w-[1.2em] h-[1.2em]" + alt="icon" + /> + 对话列表 + </div> +<!-- <hr class="line"/>--> + <el-button class="w-1/1 btn-new-conversation" type="primary" @click="createConversation"> + <img + src="@/assets/ai/zhuanlu/conversation_big.png" + class="mr-8px w-[1.5em] h-[1.5em]" + alt="icon" + /> + 开始新对话 + </el-button> + + <!-- 左顶部:搜索对话 --> + <el-input + v-model="searchName" + size="large" + class="mt-10px search-input" + placeholder="搜索历史记录" + @keyup="searchConversation" + > + <template #prefix> + <Icon icon="ep:search" /> + </template> + </el-input> + + <!-- 左中间:对话列表 --> + <div class="conversation-list"> + <!-- 情况一:加载中 --> + <el-empty v-if="loading" description="." :v-loading="loading" /> + <!-- 情况二:按照 group 分组,展示聊天会话 list 列表 --> + <div v-for="conversationKey in Object.keys(conversationMap)" :key="conversationKey"> + <div + class="conversation-item classify-title" + v-if="conversationMap[conversationKey].length" + > + <el-text class="mx-1" size="small" tag="b">{{ conversationKey }}</el-text> + </div> + <div + class="conversation-item" + v-for="conversation in conversationMap[conversationKey]" + :key="conversation.id" + @click="handleConversationClick(conversation.id)" + @mouseover="hoverConversationId = conversation.id" + @mouseout="hoverConversationId = ''" + > + <div + :class=" + conversation.id === activeConversationId ? 'conversation active' : 'conversation' + " + > + <div class="title-wrapper"> + <img class="avatar" :src="conversation.roleAvatar || roleAvatarDefaultImg" /> + <span class="title">{{ conversation.title }}</span> + </div> + <div class="button-wrapper" v-show="hoverConversationId === conversation.id"> + <el-button class="btn" link @click.stop="handleTop(conversation)"> + <el-icon title="置顶" v-if="!conversation.pinned"><Top /></el-icon> + <el-icon title="置顶" v-if="conversation.pinned"><Bottom /></el-icon> + </el-button> + <el-button class="btn" link @click.stop="updateConversationTitle(conversation)"> + <el-icon title="编辑"> + <Icon icon="ep:edit" /> + </el-icon> + </el-button> + <el-button class="btn" link @click.stop="deleteChatConversation(conversation)"> + <el-icon title="删除对话"> + <Icon icon="ep:delete" /> + </el-icon> + </el-button> + </div> + </div> + </div> + </div> + <!-- 底部占位 --> + <div class="h-160px w-100%"></div> + </div> + </div> + + <!-- 左底部:工具栏 --> + <div class="tool-box"> + <div @click="handleClearConversation"> + <Icon icon="ep:delete" /> + <el-text size="small">清空未置顶对话</el-text> + </div> + </div> + + </el-aside> +</template> + +<script setup lang="ts"> +import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation' +import { Bottom, Top } from '@element-plus/icons-vue' +import roleAvatarDefaultImg from '@/assets/ai/zhuanlu/assistant.png' + +const message = useMessage() // 消息弹窗 + +// 定义属性 +const searchName = ref<string>('') // 对话搜索 +const modelName = ref<string>('common') // 对话搜索 +const activeConversationId = ref<number | null>(null) // 选中的对话,默认为 null +const hoverConversationId = ref<number | null>(null) // 悬浮上去的对话 +const conversationList = ref([] as ChatConversationVO[]) // 对话列表 +const conversationMap = ref<any>({}) // 对话分组 (置顶、今天、三天前、一星期前、一个月前) +const loading = ref<boolean>(false) // 加载中 +const loadingTime = ref<any>() // 加载中定时器 + +// 定义组件 props +const props = defineProps({ + activeId: { + type: String || null, + required: true + } +}) + +// 定义钩子 +const emits = defineEmits([ + 'onConversationCreate', + 'onConversationClick', + 'onConversationClear', + 'onConversationDelete' +]) + +/** 搜索对话 */ +const searchConversation = async (e) => { + // 恢复数据 + if (!searchName.value.trim().length) { + conversationMap.value = await getConversationGroupByCreateTime(conversationList.value) + } else { + // 过滤 + const filterValues = conversationList.value.filter((item) => { + return item.title.includes(searchName.value.trim()) + }) + conversationMap.value = await getConversationGroupByCreateTime(filterValues) + } +} + +/** 点击对话 */ +const handleConversationClick = async (id: number) => { + // 过滤出选中的对话 + const filterConversation = conversationList.value.filter((item) => { + return item.id === id + }) + // 回调 onConversationClick + // noinspection JSVoidFunctionReturnValueUsed + const success = emits('onConversationClick', filterConversation[0]) + // 切换对话 + if (success) { + activeConversationId.value = id + } +} + +/** 获取对话列表 */ +const getChatConversationList = async () => { + try { + // 加载中 + loadingTime.value = setTimeout(() => { + loading.value = true + }, 50) + + // 1.1 获取 对话数据 + conversationList.value = await ChatConversationApi.getChatConversationEnergyList(modelName.value) + if(conversationList.value.length == 0) { + await createConversation() + } + // 1.2 排序 + conversationList.value.sort((a, b) => { + return b.createTime - a.createTime + }) + // 1.3 没有任何对话情况 + if (conversationList.value.length === 0) { + activeConversationId.value = null + conversationMap.value = {} + return + } + + // 2. 对话根据时间分组(置顶、今天、一天前、三天前、七天前、30 天前) + conversationMap.value = await getConversationGroupByCreateTime(conversationList.value) + } finally { + // 清理定时器 + if (loadingTime.value) { + clearTimeout(loadingTime.value) + } + // 加载完成 + loading.value = false + } +} + +/** 按照 creteTime 创建时间,进行分组 */ +const getConversationGroupByCreateTime = async (list: ChatConversationVO[]) => { + // 排序、指定、时间分组(今天、一天前、三天前、七天前、30天前) + // noinspection NonAsciiCharacters + const groupMap = { + 置顶: [], + 今天: [], + 一天前: [], + 三天前: [], + 七天前: [], + 三十天前: [] + } + // 当前时间的时间戳 + const now = Date.now() + // 定义时间间隔常量(单位:毫秒) + const oneDay = 24 * 60 * 60 * 1000 + const threeDays = 3 * oneDay + const sevenDays = 7 * oneDay + const thirtyDays = 30 * oneDay + for (const conversation of list) { + // 置顶 + if (conversation.pinned) { + groupMap['置顶'].push(conversation) + continue + } + // 计算时间差(单位:毫秒) + const diff = now - conversation.createTime + // 根据时间间隔判断 + if (diff < oneDay) { + groupMap['今天'].push(conversation) + } else if (diff < threeDays) { + groupMap['一天前'].push(conversation) + } else if (diff < sevenDays) { + groupMap['三天前'].push(conversation) + } else if (diff < thirtyDays) { + groupMap['七天前'].push(conversation) + } else { + groupMap['三十天前'].push(conversation) + } + } + return groupMap +} + +/** 新建对话 */ +const createConversation = async () => { + // 1. 新建对话 + const conversationId = await ChatConversationApi.createChatConversationEnergy( + {modelName: modelName.value} as unknown as ChatConversationVO + ) + // 2. 获取对话内容 + await getChatConversationList() + // 3. 选中对话 + await handleConversationClick(conversationId) + // 4. 回调 + emits('onConversationCreate') +} + +/** 修改对话的标题 */ +const updateConversationTitle = async (conversation: ChatConversationVO) => { + // 1. 二次确认 + const { value } = await ElMessageBox.prompt('修改标题', { + inputPattern: /^[\s\S]*.*\S[\s\S]*$/, // 判断非空,且非空格 + inputErrorMessage: '标题不能为空', + inputValue: conversation.title + }) + // 2. 发起修改 + await ChatConversationApi.updateChatConversationMy({ + id: conversation.id, + title: value + } as ChatConversationVO) + message.success('重命名成功') + // 3. 刷新列表 + await getChatConversationList() + // 4. 过滤当前切换的 + const filterConversationList = conversationList.value.filter((item) => { + return item.id === conversation.id + }) + if (filterConversationList.length > 0) { + // tip:避免切换对话 + if (activeConversationId.value === filterConversationList[0].id) { + emits('onConversationClick', filterConversationList[0]) + } + } +} + +/** 删除聊天对话 */ +const deleteChatConversation = async (conversation: ChatConversationVO) => { + try { + // 删除的二次确认 + await message.delConfirm(`是否确认删除对话 - ${conversation.title}?`) + // 发起删除 + await ChatConversationApi.deleteChatConversationMy(conversation.id) + message.success('对话已删除') + // 刷新列表 + await getChatConversationList() + // 回调 + emits('onConversationDelete', conversation) + } catch {} +} + +/** 清空对话 */ +const handleClearConversation = async () => { + try { + await message.confirm('确认后对话会全部清空,置顶的对话除外。') + await ChatConversationApi.deleteChatConversationMyByUnpinned() + ElMessage({ + message: '操作成功!', + type: 'success' + }) + // 清空 对话 和 对话内容 + activeConversationId.value = null + // 获取 对话列表 + await getChatConversationList() + // 回调 方法 + emits('onConversationClear') + } catch {} +} + +/** 对话置顶 */ +const handleTop = async (conversation: ChatConversationVO) => { + // 更新对话置顶 + conversation.pinned = !conversation.pinned + await ChatConversationApi.updateChatConversationMy(conversation) + // 刷新对话 + await getChatConversationList() +} + +// ============ 角色仓库 ============ + +/** 角色仓库抽屉 */ +const roleRepositoryOpen = ref<boolean>(false) // 角色仓库是否打开 +const handleRoleRepository = async () => { + roleRepositoryOpen.value = !roleRepositoryOpen.value +} + +/** 监听选中的对话 */ +const { activeId } = toRefs(props) +watch(activeId, async (newValue, oldValue) => { + activeConversationId.value = newValue as string +}) + +// 定义 public 方法 +defineExpose({ createConversation }) + +/** 初始化 */ +onMounted(async () => { + // 获取 对话列表 + await getChatConversationList() + // 默认选中 + if (props.activeId) { + activeConversationId.value = props.activeId + } else { + // 首次默认选中第一个 + if (conversationList.value.length) { + activeConversationId.value = conversationList.value[0].id + // 回调 onConversationClick + await emits('onConversationClick', conversationList.value[0]) + } + } +}) +</script> + +<style scoped lang="scss"> +.conversation-container { + position: relative; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 10px 10px 0; + overflow: hidden; + + .conversation-title { + color: #73C4FF; + line-height: 25px; + margin-bottom: 8px; + border-bottom: 1px solid rgba(69,133,255,0.2); + img { + padding: 6px 0 0 5px; + } + } + .line { + margin: 5px 0; + } + + .btn-new-conversation { + border-radius: 4px; + border: 1px solid rgba(69,133,255,0.6); + background: rgba(69,133,255,0.4); + color: #73C4FF; + } + + .search-input { + height: 30px; + border-radius: 4px; + border: 1px solid rgba(69,133,255,0.6); + background: rgba(69,133,255,0.4); + } + + .conversation-list { + overflow: auto; + height: 100%; + + .classify-title { + padding-top: 10px; + b { + color: white; + } + } + + .conversation-item { + margin-top: 5px; + } + + .conversation { + display: flex; + flex-direction: row; + justify-content: space-between; + flex: 1; + padding: 0 5px; + cursor: pointer; + border-radius: 5px; + align-items: center; + line-height: 30px; + background-color: rgba(69,133,255,0.1); + &.active { + background-color: rgba(69,133,255,0.5); + .button { + display: inline-block; + } + .title-wrapper { + > span { + font-weight: bold; + color: rgba(115, 196, 255); + } + } + } + + .title-wrapper { + display: flex; + flex-direction: row; + align-items: center; + > span { + color: rgba(115, 196, 255, 0.5); + } + } + + .title { + padding: 2px 10px; + max-width: 220px; + font-size: 14px; + font-weight: 400; + color: rgba(0, 0, 0, 0.77); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .avatar { + width: 25px; + height: 25px; + border-radius: 5px; + display: flex; + flex-direction: row; + justify-items: center; + } + + // 对话编辑、删除 + .button-wrapper { + right: 2px; + display: flex; + flex-direction: row; + justify-items: center; + color: #606266; + + .btn { + margin: 0; + color: #73C4FF; + } + } + } + } + + // 角色仓库、清空未设置对话 + .tool-box { + bottom: 0; + padding: 0 20px; + background-color: rgba(69,133,255,0.2); + box-shadow: 0 0 1px 1px rgba(69,133,255,0.4); + line-height: 35px; + justify-content: space-between; + align-items: center; + color: var(--el-text-color); + + div { + display: flex; + margin-left: 20%; + align-items: center; + color: #73C4FF; + padding: 0; + cursor: pointer; + > span { + color: #73C4FF; + margin-left: 5px; + } + } + } +} + +/* 移除所有输入框边框 */ +:deep(.el-form-item .el-input__wrapper) { + border: none !important; + box-shadow: none !important; + background: rgba(255,255,255,0.1) !important; /* 保留浅色背景 */ +} + +/* 移除输入框边框 */ +:deep(.el-input .el-input__wrapper) { + border: 1px solid #1E5A86 !important; + box-shadow: none !important; +} + +:deep(.el-input__inner) { + color: #73C4FF; +} +:deep(.el-input__wrapper) { + background: rgba(0,194,255,0.08) !important; +} +</style> diff --git a/src/views/ai/dashboard/components/conversation/CommonConversationUpdateForm.vue b/src/views/ai/dashboard/components/conversation/CommonConversationUpdateForm.vue new file mode 100644 index 0000000..afa5279 --- /dev/null +++ b/src/views/ai/dashboard/components/conversation/CommonConversationUpdateForm.vue @@ -0,0 +1,219 @@ +<template> + <DialogDashboard title="模型设定" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="130px" + v-loading="formLoading" + > + <el-form-item label="模型" prop="modelId"> + <el-select v-model="formData.modelId" disabled> + <el-option + v-for="model in models" + :key="model.id" + :label="model.name" + :value="model.id" + /> + </el-select> + </el-form-item> + <el-form-item label="温度参数" prop="temperature"> + <el-input-number + v-model="formData.temperature" + placeholder="请输入温度参数" + :min="0" + :max="2" + :precision="2" + class="!w-1/1" + /> + </el-form-item> + <el-form-item label="回复数 Token 数" prop="maxTokens"> + <el-input-number + v-model="formData.maxTokens" + placeholder="请输入回复数 Token 数" + :min="0" + :max="8192" + class="!w-1/1" + /> + </el-form-item> + <el-form-item label="上下文数量" prop="maxContexts"> + <el-input-number + v-model="formData.maxContexts" + placeholder="请输入上下文数量" + :min="0" + :max="20" + class="!w-1/1" + /> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </DialogDashboard> +</template> +<script setup lang="ts"> +import { ModelApi, ModelVO } from '@/api/ai/model/model' +import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation' +import { AiModelTypeEnum } from '@/views/ai/utils/constants' + +/** AI 聊天对话的更新表单 */ +defineOptions({ name: 'ChatConversationUpdateForm' }) + +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formData = ref({ + id: undefined, + systemMessage: undefined, + modelId: undefined, + temperature: undefined, + maxTokens: undefined, + maxContexts: undefined +}) +const formRules = reactive({ + modelId: [{ required: true, message: '模型不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }], + temperature: [{ required: true, message: '温度参数不能为空', trigger: 'blur' }], + maxTokens: [{ required: true, message: '回复数 Token 数不能为空', trigger: 'blur' }], + maxContexts: [{ required: true, message: '上下文数量不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const models = ref([] as ModelVO[]) // 聊天模型列表 + +/** 打开弹窗 */ +const open = async (id: number) => { + dialogVisible.value = true + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + const data = await ChatConversationApi.getChatConversationMy(id) + formData.value = Object.keys(formData.value).reduce((obj, key) => { + if (data.hasOwnProperty(key)) { + obj[key] = data[key] + } + return obj + }, {}) + } finally { + formLoading.value = false + } + } + // 获得下拉数据 + models.value = await ModelApi.getModelSimpleList(AiModelTypeEnum.CHAT) +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + await formRef.value.validate() + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ChatConversationVO + await ChatConversationApi.updateChatConversationMy(data) + message.success('对话配置已更新') + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + systemMessage: undefined, + modelId: undefined, + temperature: undefined, + maxTokens: undefined, + maxContexts: undefined + } + formRef.value?.resetFields() +} +</script> + +<style lang="scss" scoped> + +:deep(.el-form-item__label) { + color: #73C4FF; +} +:deep(.el-form-item__content) { + .el-input-number { + border: black solid 1px !important; + } +} +/* 移除所有输入框边框 */ +:deep(.el-form-item .el-input__wrapper) { + border: none !important; + box-shadow: none !important; + background: rgba(255,255,255,0.1) !important; /* 保留浅色背景 */ +} + +/* 移除数字输入框边框 */ +:deep(.el-input-number .el-input__wrapper) { + border: 1px solid #1E5A86 !important; + box-shadow: none !important; +} + +/* 下拉组件 */ +:deep(.el-select) { + /* 下拉箭头 */ + .el-select__caret { + color: #73C4FF !important; /* 匹配图中的浅蓝箭头 */ + font-size: 16px !important; + } +} + +/* 深度选择器调整边框细节 */ +:deep(.el-select__wrapper) { + border-radius: 6px; /* 圆角大小 */ + border-width: 1.5px; /* 边框粗细 */ + box-shadow: 0 0 0 1px #1E5A86 !important; /* 聚焦阴影 */ +} + +/* 移除按钮组边框(增减按钮) */ +:deep(.el-input-number__decrease), +:deep(.el-input-number__increase) { + border: 1px solid #1E5A86; + background: transparent !important; + i { + color: #73C4FF; + } +} +:deep(.el-loading-mask) { + background: black !important; + border-radius: 10px; +} +:deep(.el-input__inner) { + color: #73C4FF; +} +:deep(.el-select__selected-item ) { + color: #73C4FF !important; +} +:deep(.el-select__wrapper) { + background: rgba(0,194,255,0.08) !important; +} +:deep(.el-input-number span) { + background: rgba(0,194,255,0.08) !important; +} +:deep(.el-input__wrapper) { + background: rgba(0,194,255,0.08) !important; +} +.el-dialog__footer { + button { + background: rgba(0,194,255,0.08) !important; + border-color: rgba(0,194,255,0.8) !important; + } + button:first-child { + color: #73C4FF; + } +} + +</style> diff --git a/src/views/ai/dashboard/components/conversation/ConversationList.vue b/src/views/ai/dashboard/components/conversation/ConversationList.vue index 01df587..c66ff0a 100644 --- a/src/views/ai/dashboard/components/conversation/ConversationList.vue +++ b/src/views/ai/dashboard/components/conversation/ConversationList.vue @@ -149,7 +149,6 @@ // 1.1 获取 对话数据 conversationList.value = await ChatConversationApi.getChatConversationEnergyList(modelName.value) - console.log(conversationList.value) if(conversationList.value.length == 0) { await createConversation() } diff --git a/src/views/ai/dashboard/components/conversation/ConversationListEmpty.vue b/src/views/ai/dashboard/components/conversation/ConversationListEmpty.vue new file mode 100644 index 0000000..d7fef8c --- /dev/null +++ b/src/views/ai/dashboard/components/conversation/ConversationListEmpty.vue @@ -0,0 +1,78 @@ +<!-- 无聊天对话时,在 message 区域--> +<template> + <div class="conversation-empty"> + <div class="center-container"> + <div class="title no-data">暂无数据</div> + <div class="title">欢迎来到转炉煤气调度大模型</div> + </div> + </div> +</template> +<script setup lang="ts"> +const emits = defineEmits(['onNewConversation']) + +import roleAvatarDefaultImg from '@/assets/ai/zhuanlu/assistant.png' + +const roleAvatar = roleAvatarDefaultImg + +</script> +<style scoped lang="scss"> +.conversation-empty { + display: flex; + flex-direction: row; + justify-content: center; + width: 100%; + height: 100%; + + .box-center { + margin-top: 35%; + margin-left: 12%; + display: inline-block; + + .tip { + width: 120px; + height: 40px; + padding: 2px 0 0 10px; + border-radius: 2px; + font-family: Alimama ShuHeiTi, Alimama ShuHeiTi; + font-weight: bold; + font-size: 24px; + text-align: left; + font-style: normal; + text-transform: none; + background: linear-gradient(0deg, #49FFD3 0%, #4585FF 100%); + } + } + + .conversation-empty { + position: relative; + display: flex; + flex-direction: row; + justify-content: center; + width: 100%; + height: 100%; + + .center-container { + display: flex; + flex-direction: column; + justify-content: center; + + .title { + width: 120px; + height: 40px; + padding: 2px 0 0 10px; + border-radius: 2px; + font-family: Alimama ShuHeiTi, Alimama ShuHeiTi; + font-weight: bold; + font-size: 24px; + text-align: left; + font-style: normal; + text-transform: none; + background: linear-gradient(0deg, #49FFD3 0%, #4585FF 100%); + } + .no-data { + color: rgba(143,214,254,0.8) !important; + } + } + } +} +</style> diff --git a/src/views/ai/dashboard/components/conversation/HistoryConversationList.vue b/src/views/ai/dashboard/components/conversation/HistoryConversationList.vue new file mode 100644 index 0000000..fd1ec4f --- /dev/null +++ b/src/views/ai/dashboard/components/conversation/HistoryConversationList.vue @@ -0,0 +1,459 @@ +<!-- AI 对话 --> +<template> + <el-aside width="260px" class="conversation-container h-100%"> + <!-- 左顶部:对话 --> + <div class="h-100%"> + <el-button class="w-1/1 btn-new-conversation" type="primary" @click="createConversation"> + <Icon icon="ep:plus" class="mr-5px" /> + 新建对话 + </el-button> + + <!-- 左顶部:搜索对话 --> + <el-input + v-model="searchName" + size="large" + class="mt-10px search-input" + placeholder="搜索历史记录" + @keyup="searchConversation" + > + <template #prefix> + <Icon icon="ep:search" /> + </template> + </el-input> + + <!-- 左中间:对话列表 --> + <div class="conversation-list"> + <!-- 情况一:加载中 --> + <el-empty v-if="loading" description="." :v-loading="loading" /> + <!-- 情况二:按照 group 分组,展示聊天会话 list 列表 --> + <div v-for="conversationKey in Object.keys(conversationMap)" :key="conversationKey"> + <div + class="conversation-item classify-title" + v-if="conversationMap[conversationKey].length" + > + <el-text class="mx-1" size="small" tag="b">{{ conversationKey }}</el-text> + </div> + <div + class="conversation-item" + v-for="conversation in conversationMap[conversationKey]" + :key="conversation.id" + @click="handleConversationClick(conversation.id)" + @mouseover="hoverConversationId = conversation.id" + @mouseout="hoverConversationId = ''" + > + <div + :class=" + conversation.id === activeConversationId ? 'conversation active' : 'conversation' + " + > + <div class="title-wrapper"> + <img class="avatar" :src="conversation.roleAvatar || roleAvatarDefaultImg" /> + <span class="title">{{ conversation.title }}</span> + </div> + <div class="button-wrapper" v-show="hoverConversationId === conversation.id"> + <el-button class="btn" link @click.stop="handleTop(conversation)"> + <el-icon title="置顶" v-if="!conversation.pinned"><Top /></el-icon> + <el-icon title="置顶" v-if="conversation.pinned"><Bottom /></el-icon> + </el-button> + <el-button class="btn" link @click.stop="updateConversationTitle(conversation)"> + <el-icon title="编辑"> + <Icon icon="ep:edit" /> + </el-icon> + </el-button> + <el-button class="btn" link @click.stop="deleteChatConversation(conversation)"> + <el-icon title="删除对话"> + <Icon icon="ep:delete" /> + </el-icon> + </el-button> + </div> + </div> + </div> + </div> + <!-- 底部占位 --> + <div class="h-160px w-100%"></div> + </div> + </div> + + </el-aside> +</template> + +<script setup lang="ts"> +import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation' +import { Bottom, Top } from '@element-plus/icons-vue' +import roleAvatarDefaultImg from '@/assets/ai/gpt.svg' + +const message = useMessage() // 消息弹窗 + +// 定义属性 +const searchName = ref<string>('') // 对话搜索 +const modelName = ref<string>('common') // 对话搜索 +const activeConversationId = ref<number | null>(null) // 选中的对话,默认为 null +const hoverConversationId = ref<number | null>(null) // 悬浮上去的对话 +const conversationList = ref([] as ChatConversationVO[]) // 对话列表 +const conversationMap = ref<any>({}) // 对话分组 (置顶、今天、三天前、一星期前、一个月前) +const loading = ref<boolean>(false) // 加载中 +const loadingTime = ref<any>() // 加载中定时器 + +// 定义组件 props +const props = defineProps({ + activeId: { + type: String || null, + required: true + } +}) + +// 定义钩子 +const emits = defineEmits([ + 'onConversationCreate', + 'onConversationClick', + 'onConversationClear', + 'onConversationDelete' +]) + +/** 搜索对话 */ +const searchConversation = async (e) => { + // 恢复数据 + if (!searchName.value.trim().length) { + conversationMap.value = await getConversationGroupByCreateTime(conversationList.value) + } else { + // 过滤 + const filterValues = conversationList.value.filter((item) => { + return item.title.includes(searchName.value.trim()) + }) + conversationMap.value = await getConversationGroupByCreateTime(filterValues) + } +} + +/** 点击对话 */ +const handleConversationClick = async (id: number) => { + // 过滤出选中的对话 + const filterConversation = conversationList.value.filter((item) => { + return item.id === id + }) + // 回调 onConversationClick + // noinspection JSVoidFunctionReturnValueUsed + const success = emits('onConversationClick', filterConversation[0]) + // 切换对话 + if (success) { + activeConversationId.value = id + } +} + +/** 获取对话列表 */ +const getChatConversationList = async () => { + try { + // 加载中 + loadingTime.value = setTimeout(() => { + loading.value = true + }, 50) + + // 1.1 获取 对话数据 + conversationList.value = await ChatConversationApi.getChatConversationEnergyList(modelName.value) + if(conversationList.value.length == 0) { + await createConversation() + } + // 1.2 排序 + conversationList.value.sort((a, b) => { + return b.createTime - a.createTime + }) + // 1.3 没有任何对话情况 + if (conversationList.value.length === 0) { + activeConversationId.value = null + conversationMap.value = {} + return + } + + // 2. 对话根据时间分组(置顶、今天、一天前、三天前、七天前、30 天前) + conversationMap.value = await getConversationGroupByCreateTime(conversationList.value) + } finally { + // 清理定时器 + if (loadingTime.value) { + clearTimeout(loadingTime.value) + } + // 加载完成 + loading.value = false + } +} + +/** 按照 creteTime 创建时间,进行分组 */ +const getConversationGroupByCreateTime = async (list: ChatConversationVO[]) => { + // 排序、指定、时间分组(今天、一天前、三天前、七天前、30天前) + // noinspection NonAsciiCharacters + const groupMap = { + 置顶: [], + 今天: [], + 一天前: [], + 三天前: [], + 七天前: [], + 三十天前: [] + } + // 当前时间的时间戳 + const now = Date.now() + // 定义时间间隔常量(单位:毫秒) + const oneDay = 24 * 60 * 60 * 1000 + const threeDays = 3 * oneDay + const sevenDays = 7 * oneDay + const thirtyDays = 30 * oneDay + for (const conversation of list) { + // 置顶 + if (conversation.pinned) { + groupMap['置顶'].push(conversation) + continue + } + // 计算时间差(单位:毫秒) + const diff = now - conversation.createTime + // 根据时间间隔判断 + if (diff < oneDay) { + groupMap['今天'].push(conversation) + } else if (diff < threeDays) { + groupMap['一天前'].push(conversation) + } else if (diff < sevenDays) { + groupMap['三天前'].push(conversation) + } else if (diff < thirtyDays) { + groupMap['七天前'].push(conversation) + } else { + groupMap['三十天前'].push(conversation) + } + } + return groupMap +} + +/** 新建对话 */ +const createConversation = async () => { + // 1. 新建对话 + const conversationId = await ChatConversationApi.createChatConversationEnergy( + {modelName: modelName.value} as unknown as ChatConversationVO + ) + // 2. 获取对话内容 + await getChatConversationList() + // 3. 选中对话 + await handleConversationClick(conversationId) + // 4. 回调 + emits('onConversationCreate') +} + +/** 修改对话的标题 */ +const updateConversationTitle = async (conversation: ChatConversationVO) => { + // 1. 二次确认 + const { value } = await ElMessageBox.prompt('修改标题', { + inputPattern: /^[\s\S]*.*\S[\s\S]*$/, // 判断非空,且非空格 + inputErrorMessage: '标题不能为空', + inputValue: conversation.title + }) + // 2. 发起修改 + await ChatConversationApi.updateChatConversationMy({ + id: conversation.id, + title: value + } as ChatConversationVO) + message.success('重命名成功') + // 3. 刷新列表 + await getChatConversationList() + // 4. 过滤当前切换的 + const filterConversationList = conversationList.value.filter((item) => { + return item.id === conversation.id + }) + if (filterConversationList.length > 0) { + // tip:避免切换对话 + if (activeConversationId.value === filterConversationList[0].id) { + emits('onConversationClick', filterConversationList[0]) + } + } +} + +/** 删除聊天对话 */ +const deleteChatConversation = async (conversation: ChatConversationVO) => { + try { + // 删除的二次确认 + await message.delConfirm(`是否确认删除对话 - ${conversation.title}?`) + // 发起删除 + await ChatConversationApi.deleteChatConversationMy(conversation.id) + message.success('对话已删除') + // 刷新列表 + await getChatConversationList() + // 回调 + emits('onConversationDelete', conversation) + } catch {} +} + +/** 清空对话 */ +const handleClearConversation = async () => { + try { + await message.confirm('确认后对话会全部清空,置顶的对话除外。') + await ChatConversationApi.deleteChatConversationMyByUnpinned() + ElMessage({ + message: '操作成功!', + type: 'success' + }) + // 清空 对话 和 对话内容 + activeConversationId.value = null + // 获取 对话列表 + await getChatConversationList() + // 回调 方法 + emits('onConversationClear') + } catch {} +} + +/** 对话置顶 */ +const handleTop = async (conversation: ChatConversationVO) => { + // 更新对话置顶 + conversation.pinned = !conversation.pinned + await ChatConversationApi.updateChatConversationMy(conversation) + // 刷新对话 + await getChatConversationList() +} + +// ============ 角色仓库 ============ + +/** 角色仓库抽屉 */ +const roleRepositoryOpen = ref<boolean>(false) // 角色仓库是否打开 +const handleRoleRepository = async () => { + roleRepositoryOpen.value = !roleRepositoryOpen.value +} + +/** 监听选中的对话 */ +const { activeId } = toRefs(props) +watch(activeId, async (newValue, oldValue) => { + activeConversationId.value = newValue as string +}) + +// 定义 public 方法 +defineExpose({ createConversation }) + +/** 初始化 */ +onMounted(async () => { + // 获取 对话列表 + await getChatConversationList() + // 默认选中 + if (props.activeId) { + activeConversationId.value = props.activeId + } else { + // 首次默认选中第一个 + if (conversationList.value.length) { + activeConversationId.value = conversationList.value[0].id + // 回调 onConversationClick + await emits('onConversationClick', conversationList.value[0]) + } + } +}) +</script> + +<style scoped lang="scss"> +.conversation-container { + position: relative; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 10px 10px 0; + overflow: hidden; + + .btn-new-conversation { + padding: 18px 0; + } + + .search-input { + margin-top: 20px; + } + + .conversation-list { + overflow: auto; + height: 100%; + + .classify-title { + padding-top: 10px; + } + + .conversation-item { + margin-top: 5px; + } + + .conversation { + display: flex; + flex-direction: row; + justify-content: space-between; + flex: 1; + padding: 0 5px; + cursor: pointer; + border-radius: 5px; + align-items: center; + line-height: 30px; + + &.active { + background-color: #e6e6e6; + + .button { + display: inline-block; + } + } + + .title-wrapper { + display: flex; + flex-direction: row; + align-items: center; + } + + .title { + padding: 2px 10px; + max-width: 220px; + font-size: 14px; + font-weight: 400; + color: rgba(0, 0, 0, 0.77); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .avatar { + width: 25px; + height: 25px; + border-radius: 5px; + display: flex; + flex-direction: row; + justify-items: center; + } + + // 对话编辑、删除 + .button-wrapper { + right: 2px; + display: flex; + flex-direction: row; + justify-items: center; + color: #606266; + + .btn { + margin: 0; + } + } + } + } + + // 角色仓库、清空未设置对话 + .tool-box { + position: absolute; + bottom: 0; + left: 0; + right: 0; + //width: 100%; + padding: 0 20px; + background-color: #f4f4f4; + box-shadow: 0 0 1px 1px rgba(228, 228, 228, 0.8); + line-height: 35px; + display: flex; + justify-content: space-between; + align-items: center; + color: var(--el-text-color); + + > div { + display: flex; + align-items: center; + color: #606266; + padding: 0; + margin: 0; + cursor: pointer; + + > span { + margin-left: 5px; + } + } + } +} +</style> diff --git a/src/views/ai/dashboard/components/message/HistoryMessageDialog.vue b/src/views/ai/dashboard/components/message/HistoryMessageDialog.vue new file mode 100644 index 0000000..68b1a73 --- /dev/null +++ b/src/views/ai/dashboard/components/message/HistoryMessageDialog.vue @@ -0,0 +1,375 @@ +<template> + <DialogHistory title="历史建议" v-model="dialogVisible" width="1200"> + <!-- 左侧:对话列表 --> + <ConversationList + v-show="false" + :active-id="activeConversationId" + ref="conversationListRef" + /> + <!-- 右侧:对话详情 --> + <el-container class="detail-container"> + <el-header class="header"> + <div class="title"> + {{ activeConversation?.title ? activeConversation?.title : '' }} + <span v-if="activeMessageList.length">({{ activeMessageList.length }})</span> + </div> + <div class="btns" v-if="activeConversation"> + <el-button size="small" class="btn" @click="handlerMessageClear"> + <Icon icon="heroicons-outline:archive-box-x-mark" color="#73C4FF" /> + </el-button> + <el-button size="small" class="btn" @click="handleGoBottomMessage"> + <Icon icon="ep:download" color="#73C4FF" /> + </el-button> + <el-button size="small" class="btn" @click="handleGoTopMessage"> + <Icon icon="ep:top" color="#73C4FF" /> + </el-button> + </div> + </el-header> + + <!-- main:消息列表 --> + <el-main class="main-container"> + <div class="message-container"> + <!-- 情况一:无聊天对话时 --> + <ConversationListEmpty + v-if="!activeConversation" + /> + <!-- 情况二:消息列表为空 --> + <MessageListEmpty + v-if="activeMessageList.length === 0 && activeConversation" + /> + <!-- 情况三:消息列表不为空 --> + <HistoryMessageList + v-if="activeMessageList.length > 0" + ref="messageRef" + :conversation="activeConversation" + :list="activeMessageList" + /> + </div> + </el-main> + </el-container> + + </DialogHistory> +</template> + +<script setup lang="ts"> +import {ChatMessageApi, ChatMessageVO} from '@/api/ai/chat/message' +import { ChatConversationVO } from '@/api/ai/chat/conversation' +import ConversationList from '../conversation/HistoryConversationList.vue' +import HistoryMessageList from './HistoryMessageList.vue' +import MessageListEmpty from './MessageListEmpty.vue' +import ConversationListEmpty from '../conversation/ConversationListEmpty.vue' + +/** AI 聊天对话 列表 */ +defineOptions({ name: 'HistoryMessageDialog' }) + +const route = useRoute() // 路由 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 + +// 聊天对话 +const conversationListRef = ref() +const activeConversation = ref<ChatConversationVO | null>(null) // 选中的 Conversation + +// 消息列表 +const messageRef = ref() +const activeMessageList = ref<ChatMessageVO[]>([]) // 选中对话的消息列表 + + +/** 打开弹窗 */ +const open = async (messages: ChatMessageVO[], conversation: ChatConversationVO) => { + dialogVisible.value = true + await nextTick() // 等待弹窗DOM挂载 + activeMessageList.value = messages + activeConversation.value = conversation +} + +defineExpose({ open }) // 提供方法给 parent 调用 + +/** 回到 message 列表的顶部 */ +const handleGoTopMessage = () => { + messageRef.value.handlerGoTop() +} + +/** 回到 message 列表的底部 */ +const handleGoBottomMessage = () => { + messageRef.value.handleGoBottom() +} + +/** 处理 message 清空 */ +const handlerMessageClear = async () => { + if (!activeConversation.value) { + return + } + try { + // 确认提示 + await message.delConfirm('确认清空对话消息?') + // 清空对话 + await ChatMessageApi.deleteByConversationId(activeConversation.value.id) + // 刷新 message 列表 + activeMessageList.value = [] + } catch {} +} + +/** 初始化 **/ +onMounted(async () => { +}) +</script> + +<style lang="scss" scoped> + +// 头部 +.detail-container { + display: flex; + flex-direction: column; + width: 100%; + height: 820px; + background-color: rgba(0, 0, 0, 0); /* 透明背景 */ + z-index: 1; + .header { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + box-shadow: 0 0 0 0 #dcdfe6; + + .title { + font-size: 18px; + font-weight: bold; + color: gold; + } + + .btns { + display: flex; + width: 300px; + flex-direction: row; + justify-content: flex-end; + + .btn { + padding: 10px; + } + + /* 所有状态通用透明背景 */ + :deep(.el-button) { + background: transparent !important; + border-color: currentColor; /* 保持与文字同色 */ + color: #409EFF; /* 蓝色文字 */ + } + + /* 悬停状态 */ + :deep(.el-button:hover) { + background: rgba(0, 0, 0, 0.05) !important; /* 轻微悬停反馈 */ + } + + /* 点击状态 */ + :deep(.el-button:active) { + background: rgba(0, 0, 0, 0.1) !important; + } + + /* 禁用状态 */ + :deep(.el-button.is-disabled) { + opacity: 0.6; + background: transparent !important; + } + } + } +} + +// main 容器 +.main-container { + padding: 0; + position: relative; + overflow: hidden; /* 隐藏外层滚动条 */ + width: 100%; + + .message-container { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + overflow-y: hidden; + /* Firefox */ + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.15) transparent; + + /* WebKit */ + &::-webkit-scrollbar { + width: 6px; + background: transparent; + } + &::-webkit-scrollbar-thumb { + border-radius: 4px; + background: rgba(0, 0, 0, 0.15); + transition: background 0.3s; + &:hover { background: rgba(0, 0, 0, 0.25); } + } + } +} + +// 底部 +.footer-container { + display: flex; + flex-direction: column; + height: 114px; + margin-left: 10px; + padding: 0; + + // 输入框 + .input-container { + display: flex; + flex-direction: column; + height: auto; + width: 876px; + margin: 0; + padding: 0; + overflow-y: auto; /* 垂直方向溢出时显示滚动条 */ + overflow-x: hidden; /* 水平方向隐藏滚动条 */ + /* Firefox */ + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.15) transparent; + + /* WebKit */ + &::-webkit-scrollbar { + width: 6px; + background: transparent; + } + &::-webkit-scrollbar-thumb { + border-radius: 4px; + background: rgba(0, 0, 0, 0.15); + transition: background 0.3s; + &:hover { background: rgba(0, 0, 0, 0.25); } + } + + .prompt-from { + display: flex; + flex-direction: column; + padding: 9px 10px; + width: 876px; + height: 114px; + background: rgba(115,196,255,0.05); + border-radius: 4px 4px 4px 4px; + border: 1px solid #73C4FF; + } + + .prompt-input { + width: 876px; + height: 113.55px; + font-weight: 400; + font-size: 14px; + background-color: rgba(219,238,255,0); + line-height: 21px; + text-align: left; + font-style: normal; + text-transform: none; + border: 0; + color: rgba(219,238,255,0.6); + } + + .prompt-input:focus { + outline: none; + } + + .prompt-btns { + display: flex; + justify-content: space-between; + padding-bottom: 0; + padding-top: 5px; + + .content { + /* 默认状态 */ + .el-button { + background: transparent !important; + border-color: rgba(115, 196, 255, 0.5); + color: #73C4FF; + border-radius: 15px !important; + } + + /* 上下文图标处理 */ + .content-icon { + color: blue; /* 图标颜色 */ + font-size: 18px; + margin-right: 10px; + background: url("@/assets/ai/zhuanlu/content.png"); + vertical-align: middle; + } + + /* 选中状态 */ + .active-button { + background: #409eff !important; + border-color: #409eff !important; + color: white !important; + .content-icon { + background: url("@/assets/ai/zhuanlu/content_select.png"); + vertical-align: middle; + } + } + + /* 按钮组间距处理 */ + .button-group .el-button { + margin-left: 0; + border-radius: 4px; + } + + /* 悬停效果 */ + .el-button:not(.active-button):hover { + border-color: rgba(115,196,255,0.5); + color: #409eff; + } + } + .message { + /* 所有状态通用透明背景 */ + :deep(.el-button) { + background: rgba(73, 254, 210, 0.8) !important; + border-color: currentColor; /* 保持与文字同色 */ + font-family: Alimama ShuHeiTi, Alimama ShuHeiTi; + font-weight: bold; + font-size: 16px; + color: #123C4E; + clip-path: polygon( + 0 0, + 100% 0, + 100% 100%, + 10px 100%, /* 右下方向留出10px */ + 0 calc(100% - 10px) /* 左上方向留出10px */ + ); + position: relative; + padding-left: 15px; /* 增加右侧留白 */ + } + + /* 悬停状态 */ + :deep(.el-button:hover) { + background: rgba(73, 254, 210, 0.6) !important; /* 轻微悬停反馈 */ + } + + /* 点击状态 */ + :deep(.el-button:active) { + background: rgba(73, 254, 210, 1) !important; + } + + /* 禁用状态 */ + :deep(.el-button.is-disabled) { + opacity: 0.6; + background: transparent !important; + } + + /* 核心样式覆盖 */ + :deep(.el-switch__core) { + background: transparent !important; + border-radius: 0 0 15px 0 !important; + border: none !important; + height: 40px !important; + } + + /* 按钮内容容器 */ + .button-content { + display: flex; + align-items: center; + padding: 0 15px; + height: 100%; + } + } + } + } +} +</style> diff --git a/src/views/ai/dashboard/components/message/HistoryMessageList.vue b/src/views/ai/dashboard/components/message/HistoryMessageList.vue new file mode 100644 index 0000000..91371e1 --- /dev/null +++ b/src/views/ai/dashboard/components/message/HistoryMessageList.vue @@ -0,0 +1,290 @@ +<template> + <div ref="messageContainer" class="h-100% overflow-y-auto relative"> + <div class="chat-list" v-for="(item, index) in list" :key="index"> + <!-- 靠左 message:system、assistant 类型 --> + <div class="left-message message-item" v-if="item.type !== 'user'"> + <div class="avatar"> + <el-avatar :src="roleAvatar" /> + </div> + <div class="message"> + <div> + <el-text class="time">{{ formatDate(item.createTime) }}</el-text> + </div> + <div v-if="item.thinkingFlag" class="left-text-container-thinking" ref="markdownViewRef"> + <MarkdownView v-if="item.thinking" class="left-text thinking" :content="item.thinking" /> + <MarkdownView v-else class="left-text thinking" :content="item.content" /> + </div> + <div v-else-if="item.thinking" class="left-text-container-thinking" ref="markdownViewRef"> + <MarkdownView class="left-text thinking" :content="item.thinking" /> + </div> + <div v-else class="left-text-container-conclusion" ref="markdownViewRef"> + <MarkdownView class="left-text" :content="item.content" /> + </div> + <div class="left-text-container-conclusion" ref="markdownViewRef"> + <MarkdownView class="left-text" :content="item.conclusion" /> + </div> + <div class="right-btns"> + <el-button class="btn-cus" link @click="copyContent(item.content)"> + <img class="btn-image" src="@/assets/ai/zhuanlu/copy.png" /> + </el-button> + <!-- 暂时不能删除,随意删除会影响首页echarts图表展示 --> +<!-- <el-button v-if="item.id > 0" class="btn-cus" link @click="onDelete(item.id)">--> +<!-- <img class="btn-image h-17px" src="@/assets/ai/zhuanlu/delete.png" />--> +<!-- </el-button>--> + </div> + </div> + </div> + <!-- 靠右 message:user 类型 --> + <div class="left-message message-item" v-if="item.type === 'user'"> + <div class="avatar"> + <el-avatar :src="userAvatar" /> + </div> + <div class="message"> + <div> + <el-text class="time">{{ formatDate(item.createTime) }}</el-text> + </div> + <div class="right-text-container"> + <div class="right-text">{{ item.content }}</div> + </div> + <div class="right-btns"> + <el-button class="btn-cus" link @click="copyContent(item.content)"> + <img class="btn-image" src="@/assets/ai/zhuanlu/copy.png" /> + </el-button> + <el-button class="btn-cus" link @click="onDelete(item.id)"> + <img class="btn-image h-17px mr-12px" src="@/assets/ai/zhuanlu/delete.png" /> + </el-button> + </div> + </div> + </div> + </div> + </div> + <!-- 回到底部 --> + <div v-if="isScrolling" class="to-bottom" @click="handleGoBottom"> + <el-button :icon="ArrowDownBold" circle /> + </div> +</template> +<script setup lang="ts"> +import { PropType } from 'vue' +import { formatDate } from '@/utils/formatTime' +import MarkdownView from '@/components/MarkdownView/index.vue' +import { useClipboard } from '@vueuse/core' +import { ArrowDownBold } from '@element-plus/icons-vue' +import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message' +import { ChatConversationVO } from '@/api/ai/chat/conversation' +import { useUserStore } from '@/store/modules/user' +import userAvatarDefaultImg from '@/assets/ai/zhuanlu/user.png' +import roleAvatarDefaultImg from '@/assets/ai/zhuanlu/assistant.png' + +const dialogVisible = ref(false) // 弹窗的是否展示 + +const message = useMessage() // 消息弹窗 +const { copy } = useClipboard() // 初始化 copy 到粘贴板 +const userStore = useUserStore() + +// 判断“消息列表”滚动的位置(用于判断是否需要滚动到消息最下方) +const messageContainer: any = ref(null) +const isScrolling = ref(false) //用于判断用户是否在滚动 + +const userAvatar = computed(() => userAvatarDefaultImg) +const roleAvatar = computed(() => props.conversation.roleAvatar ?? roleAvatarDefaultImg) + +// 定义 props +const props = defineProps({ + conversation: { + type: Object as PropType<ChatConversationVO>, + required: true + }, + list: { + type: Array as PropType<ChatMessageVO[]>, + required: true + } +}) + +const { list } = toRefs(props) // 消息列表 + +const emits = defineEmits(['onDeleteSuccess']) // 定义 emits + +// ============ 处理对话滚动 ============== + +/** 滚动到底部 */ +const scrollToBottom = async (isIgnore?: boolean) => { + // 注意要使用 nextTick 以免获取不到 dom + await nextTick() + if (isIgnore || !isScrolling.value) { + messageContainer.value.scrollTop = + messageContainer.value.scrollHeight - messageContainer.value.offsetHeight + } +} + +function handleScroll() { + const scrollContainer = messageContainer.value + const scrollTop = scrollContainer.scrollTop + const scrollHeight = scrollContainer.scrollHeight + const offsetHeight = scrollContainer.offsetHeight + if (scrollTop + offsetHeight < scrollHeight - 100) { + // 用户开始滚动并在最底部之上,取消保持在最底部的效果 + isScrolling.value = true + } else { + // 用户停止滚动并滚动到最底部,开启保持到最底部的效果 + isScrolling.value = false + } +} + +/** 回到底部 */ +const handleGoBottom = async () => { + const scrollContainer = messageContainer.value + scrollContainer.scrollTop = scrollContainer.scrollHeight +} + +/** 回到顶部 */ +const handlerGoTop = async () => { + const scrollContainer = messageContainer.value + scrollContainer.scrollTop = 0 +} + +defineExpose({ scrollToBottom, handlerGoTop, handleGoBottom }) // 提供方法给 parent 调用 + +// ============ 处理消息操作 ============== + +/** 复制 */ +const copyContent = async (content) => { + await copy(content) + message.success('复制成功!') +} + +/** 删除 */ +const onDelete = async (id) => { + // 删除 message + await ChatMessageApi.deleteChatMessage(id) + message.success('删除成功!') + // 回调 + emits('onDeleteSuccess') +} + +/** 初始化 */ +onMounted(async () => { + messageContainer.value.addEventListener('scroll', handleScroll) +}) +</script> + +<style scoped lang="scss"> +/* 添加或修改以下样式 */ +div[ref="messageContainer"] { + height: 100%; /* 继承父容器高度 */ + overflow-y: auto; /* 启用垂直滚动 */ + max-height: 720px; /* 或根据实际需求调整 */ + padding-bottom: 20px; /* 避免底部内容被截断 */ +} +// 中间 +.chat-list { + display: flex; + flex-direction: column; + height: auto; + padding: 0 20px; + + .left-message { + display: flex; + flex-direction: row; + } + + .message { + display: flex; + flex-direction: column; + text-align: left; + margin: 0 15px; + + .time { + text-align: left; + line-height: 30px; + } + + .left-text-container-thinking { + position: relative; + display: flex; + flex-direction: column; + overflow-wrap: break-word; + background: rgba(115,196,255,0.05); + border-radius: 4px 4px 4px 4px; + border-left: 1px solid #73C4FF; + padding: 10px 10px 5px 10px; + .left-text { + color: rgba(219,238,255,0.5); + font-size: 0.85rem; + } + } + + .left-text-container-conclusion { + position: relative; + display: flex; + flex-direction: column; + overflow-wrap: break-word; + background: rgba(115,196,255,0); + border-radius: 4px 4px 4px 4px; + padding: 0 10px 5px 0; + .left-text { + color: rgba(219,238,255,0.8); + font-size: 1rem; + } + } + + .right-text-container { + display: flex; + flex-direction: row-reverse; + + .right-text { + font-size: 0.95rem; + color: #DBEEFF; + display: inline; + background: rgba(40,139,255,0.1); + box-shadow: 0 0 0 0 rgba(40,139,255,0.3); + border-radius: 10px; + padding: 10px; + width: auto; + overflow-wrap: break-word; + white-space: pre-wrap; + } + } + + .left-btns { + display: flex; + flex-direction: row; + margin-top: 8px; + } + + .right-btns { + display: flex; + flex-direction: row-reverse; + margin-top: 8px; + } + } + + // 复制、删除按钮 + .btn-cus { + display: flex; + background-color: transparent; + align-items: center; + + .btn-image { + height: 20px; + } + } + + .btn-cus:hover { + cursor: pointer; + background-color: #f6f6f6; + } +} + +// 回到底部 +.to-bottom { + position: absolute; + z-index: 1000; + bottom: 0; + right: 50%; + .el-button { + background: rgba(255,215,0,0.2); + border: solid 1px rgba(255,215,0,0.8); + color: rgba(255,215,0,0.8); + } +} +</style> diff --git a/src/views/ai/dashboard/components/message/MessageList.vue b/src/views/ai/dashboard/components/message/MessageList.vue index 0caa248..616b9f4 100644 --- a/src/views/ai/dashboard/components/message/MessageList.vue +++ b/src/views/ai/dashboard/components/message/MessageList.vue @@ -3,9 +3,58 @@ <div class="chat-list" v-for="(item, index) in list" :key="index"> <!-- 靠左 message:system、assistant 类型 --> <div class="left-message message-item" v-if="item.type !== 'user'"> + <div class="avatar"> + <el-avatar :src="roleAvatar" /> + </div> <div class="message"> - <div class="left-text-container" ref="markdownViewRef"> - <MarkdownView class="left-text" :content="item.content" /> + <div> + <el-text class="time">{{ formatDate(item.createTime) }}</el-text> + </div> + <div v-if="item.thinkingFlag" class="left-text-container-thinking" ref="markdownViewRef"> + <MarkdownView v-if="item.thinking" class="left-text thinking" :content="item.thinking" /> + <MarkdownView v-else class="left-text thinking" :content="item.content" /> + </div> + <div v-else-if="item.thinking" class="left-text-container-thinking" ref="markdownViewRef"> + <MarkdownView class="left-text thinking" :content="item.thinking" /> + </div> + <div class="left-text-container-conclusion" ref="markdownViewRef"> + <MarkdownView class="left-text" :content="item.conclusion" /> + </div> + <div class="left-btns"> + <el-button class="btn-cus" link @click="copyContent(item.content)"> + <img class="btn-image" src="@/assets/ai/zhuanlu/copy.png" /> + </el-button> + <el-button v-if="item.id > 0" class="btn-cus" link @click="onDelete(item.id)"> + <img class="btn-image h-17px" src="@/assets/ai/zhuanlu/delete.png" /> + </el-button> + </div> + </div> + </div> + <!-- 靠右 message:user 类型 --> + <div class="right-message message-item" v-if="item.type === 'user'"> + <div class="avatar"> + <el-avatar :src="userAvatar" /> + </div> + <div class="message"> + <div> + <el-text class="time">{{ formatDate(item.createTime) }}</el-text> + </div> + <div class="right-text-container"> + <div class="right-text">{{ item.content }}</div> + </div> + <div class="right-btns"> + <el-button class="btn-cus" link @click="copyContent(item.content)"> + <img class="btn-image" src="@/assets/ai/zhuanlu/copy.png" /> + </el-button> + <el-button class="btn-cus" link @click="onDelete(item.id)"> + <img class="btn-image h-17px mr-12px" src="@/assets/ai/zhuanlu/delete.png" /> + </el-button> + <el-button class="btn-cus" link @click="onRefresh(item)"> + <img class="btn-image h-17px mr-12px" src="@/assets/ai/zhuanlu/refresh.png" /> + </el-button> + <el-button class="btn-cus" link @click="onEdit(item)"> + <img class="btn-image h-17px mr-12px" src="@/assets/ai/zhuanlu/edit.png" /> + </el-button> </div> </div> </div> @@ -18,12 +67,15 @@ </template> <script setup lang="ts"> import { PropType } from 'vue' +import { formatDate } from '@/utils/formatTime' import MarkdownView from '@/components/MarkdownView/index.vue' import { useClipboard } from '@vueuse/core' import { ArrowDownBold} from '@element-plus/icons-vue' import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message' import { ChatConversationVO } from '@/api/ai/chat/conversation' import { useUserStore } from '@/store/modules/user' +import userAvatarDefaultImg from '@/assets/ai/zhuanlu/user.png' +import roleAvatarDefaultImg from '@/assets/ai/zhuanlu/assistant.png' const message = useMessage() // 消息弹窗 const { copy } = useClipboard() // 初始化 copy 到粘贴板 @@ -32,6 +84,10 @@ // 判断“消息列表”滚动的位置(用于判断是否需要滚动到消息最下方) const messageContainer: any = ref(null) const isScrolling = ref(false) //用于判断用户是否在滚动 + +const userAvatar = computed(() => userAvatarDefaultImg) +// const userAvatar = computed(() => userStore.user.avatar || userAvatarDefaultImg) +const roleAvatar = computed(() => props.conversation.roleAvatar ?? roleAvatarDefaultImg) // 定义 props const props = defineProps({ @@ -78,16 +134,18 @@ /** 回到底部 */ const handleGoBottom = async () => { const scrollContainer = messageContainer.value + console.log(scrollContainer.scrollHeight) scrollContainer.scrollTop = scrollContainer.scrollHeight } /** 回到顶部 */ const handlerGoTop = async () => { const scrollContainer = messageContainer.value + console.log(scrollContainer.scrollHeight) scrollContainer.scrollTop = 0 } -defineExpose({ scrollToBottom, handlerGoTop }) // 提供方法给 parent 调用 +defineExpose({ scrollToBottom, handlerGoTop, handleGoBottom }) // 提供方法给 parent 调用 // ============ 处理消息操作 ============== @@ -100,7 +158,7 @@ /** 删除 */ const onDelete = async (id) => { // 删除 message - await ChatMessageApi.deleteEnergyChatMessage(id) + await ChatMessageApi.deleteChatMessage(id) message.success('删除成功!') // 回调 emits('onDeleteSuccess') @@ -143,15 +201,24 @@ flex-direction: row; } + .right-message { + display: flex; + flex-direction: row-reverse; + justify-content: flex-start; + } + .message { display: flex; flex-direction: column; text-align: left; - height: 462px; + margin: 0 15px; - .left-text-container { - width: 855px; - height: 462px; + .time { + text-align: left; + line-height: 30px; + } + + .left-text-container-thinking { position: relative; display: flex; flex-direction: column; @@ -160,14 +227,55 @@ border-radius: 4px 4px 4px 4px; border-left: 1px solid #73C4FF; padding: 10px 10px 5px 10px; - .left-text { - font-weight: 400; - font-size: 14px; - color: rgba(219,238,255,0.6); + color: rgba(219,238,255,0.5); + font-size: 0.85rem; } } + .left-text-container-conclusion { + position: relative; + display: flex; + flex-direction: column; + overflow-wrap: break-word; + background: rgba(115,196,255,0); + border-radius: 4px 4px 4px 4px; + padding: 20px 10px 5px 0; + .left-text { + color: rgba(219,238,255,0.8); + font-size: 1rem; + } + } + + .right-text-container { + display: flex; + flex-direction: row-reverse; + + .right-text { + font-size: 0.95rem; + color: #DBEEFF; + display: inline; + background: rgba(40,139,255,0.1); + box-shadow: 0 0 0 1px rgba(40,139,255,0.3); + border-radius: 10px; + padding: 10px; + width: auto; + overflow-wrap: break-word; + white-space: pre-wrap; + } + } + + .left-btns { + display: flex; + flex-direction: row; + margin-top: 8px; + } + + .right-btns { + display: flex; + flex-direction: row-reverse; + margin-top: 8px; + } } // 复制、删除按钮 diff --git a/src/views/ai/dashboard/components/message/MessageListEmpty.vue b/src/views/ai/dashboard/components/message/MessageListEmpty.vue index 5d30a87..e746f74 100644 --- a/src/views/ai/dashboard/components/message/MessageListEmpty.vue +++ b/src/views/ai/dashboard/components/message/MessageListEmpty.vue @@ -3,26 +3,15 @@ <div class="chat-empty"> <!-- title --> <div class="center-container"> - <div class="title">工业大模型 AI</div> + <div class="avatar"><img src="@/assets/ai/zhuanlu/assistant.png" /></div> + <div class="gradient-text">欢迎来到转炉煤气调度大模型</div> </div> </div> </template> <script setup lang="ts"> -const promptList = [ - { - prompt: '今天气怎么样?' - }, - { - prompt: '写一首好听的诗歌?' - } -] // prompt 列表 const emits = defineEmits(['onPrompt']) -/** 选中 prompt 点击 */ -const handlerPromptClick = async ({ prompt }) => { - emits('onPrompt', prompt) -} </script> <style scoped lang="scss"> .chat-empty { @@ -32,42 +21,29 @@ justify-content: center; width: 100%; height: 100%; - .center-container { - display: flex; - flex-direction: column; - justify-content: center; + margin-top: 30%; + display: inline-block; - .title { - font-size: 28px; - font-weight: bold; - text-align: center; - color: #8FD6FE; + div { + float: left; } - .role-list { - display: flex; - flex-direction: row; - flex-wrap: wrap; - align-items: center; - justify-content: center; - width: 460px; - margin-top: 20px; - - .role-item { - display: flex; - justify-content: center; - width: 180px; - line-height: 50px; - border: 1px solid #e4e4e4; - border-radius: 10px; - margin: 10px; - cursor: pointer; - } - - .role-item:hover { - background-color: rgba(243, 243, 243, 0.73); - } + .avatar { + background: transparent; + width: 50px; + height: 50px; + position: relative; + } + /* 渐变文字样式 */ + .gradient-text { + font-family: Alimama ShuHeiTi, Alimama ShuHeiTi; + font-weight: bold; + font-size: 24px; + background: linear-gradient(0deg, #49FFD3 0%, #4585FF 100%); + -webkit-background-clip: text; + color: transparent; + margin: 4px 0 0 10px; } } } diff --git a/src/views/ai/dashboard/components/message/ModelMessageList.vue b/src/views/ai/dashboard/components/message/ModelMessageList.vue new file mode 100644 index 0000000..f46b93e --- /dev/null +++ b/src/views/ai/dashboard/components/message/ModelMessageList.vue @@ -0,0 +1,193 @@ +<template> + <div ref="messageContainer" class="h-100%"> + <div class="chat-list" v-for="(item, index) in list" :key="index"> + <!-- 靠左 message:system、assistant 类型 --> + <div class="left-message message-item" v-if="item.type !== 'user'"> + <div class="message"> + <div class="left-text-container" ref="markdownViewRef"> + <MarkdownView class="left-text" :content="item.thinking" /> + </div> + </div> + </div> + </div> + </div> + <!-- 回到底部 --> + <div v-if="isScrolling" class="to-bottom" @click="handleGoBottom"> + <el-button :icon="ArrowDownBold" circle /> + </div> +</template> +<script setup lang="ts"> +import { PropType } from 'vue' +import MarkdownView from '@/components/MarkdownView/index.vue' +import { useClipboard } from '@vueuse/core' +import { ArrowDownBold} from '@element-plus/icons-vue' +import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message' +import { ChatConversationVO } from '@/api/ai/chat/conversation' +import { useUserStore } from '@/store/modules/user' +import {formatDate} from "@/utils/formatTime"; + +const message = useMessage() // 消息弹窗 +const { copy } = useClipboard() // 初始化 copy 到粘贴板 +const userStore = useUserStore() + +// 判断“消息列表”滚动的位置(用于判断是否需要滚动到消息最下方) +const messageContainer: any = ref(null) +const isScrolling = ref(false) //用于判断用户是否在滚动 + +// 定义 props +const props = defineProps({ + conversation: { + type: Object as PropType<ChatConversationVO>, + required: true + }, + list: { + type: Array as PropType<ChatMessageVO[]>, + required: true + } +}) + +const { list } = toRefs(props) // 消息列表 + +const emits = defineEmits(['onDeleteSuccess', 'onRefresh', 'onEdit']) // 定义 emits + +// ============ 处理对话滚动 ============== + +/** 滚动到底部 */ +const scrollToBottom = async (isIgnore?: boolean) => { + // 注意要使用 nextTick 以免获取不到 dom + await nextTick() + if (isIgnore || !isScrolling.value) { + messageContainer.value.scrollTop = + messageContainer.value.scrollHeight - messageContainer.value.offsetHeight + } +} + +function handleScroll() { + const scrollContainer = messageContainer.value + const scrollTop = scrollContainer.scrollTop + const scrollHeight = scrollContainer.scrollHeight + const offsetHeight = scrollContainer.offsetHeight + if (scrollTop + offsetHeight < scrollHeight - 100) { + // 用户开始滚动并在最底部之上,取消保持在最底部的效果 + isScrolling.value = true + } else { + // 用户停止滚动并滚动到最底部,开启保持到最底部的效果 + isScrolling.value = false + } +} + +/** 回到底部 */ +const handleGoBottom = async () => { + const scrollContainer = messageContainer.value + scrollContainer.scrollTop = scrollContainer.scrollHeight +} + +/** 回到顶部 */ +const handlerGoTop = async () => { + const scrollContainer = messageContainer.value + scrollContainer.scrollTop = 0 +} + +defineExpose({ scrollToBottom, handlerGoTop }) // 提供方法给 parent 调用 + +// ============ 处理消息操作 ============== + +/** 复制 */ +const copyContent = async (content) => { + await copy(content) + message.success('复制成功!') +} + +/** 删除 */ +const onDelete = async (id) => { + // 删除 message + await ChatMessageApi.deleteChatMessage(id) + message.success('删除成功!') + // 回调 + emits('onDeleteSuccess') +} + +/** 刷新 */ +const onRefresh = async (message: ChatMessageVO) => { + emits('onRefresh', message) +} + +/** 编辑 */ +const onEdit = async (message: ChatMessageVO) => { + emits('onEdit', message) +} + +/** 初始化 */ +onMounted(async () => { + messageContainer.value.addEventListener('scroll', handleScroll) +}) +</script> + +<style scoped lang="scss"> +.message-container { + position: relative; + overflow-y: scroll; +} + +// 中间 +.chat-list { + display: flex; + flex-direction: column; + overflow-y: hidden; + padding: 0 20px; + .message-item { + margin-top: 30px; + } + + .message { + display: flex; + flex-direction: column; + text-align: left; + height: 480px; + + .left-text-container { + width: 855px; + height: 450px; + position: relative; + display: flex; + flex-direction: column; + overflow-wrap: break-word; + background: rgba(115,196,255,0.05); + border-radius: 4px 4px 4px 4px; + border-left: 1px solid #73C4FF; + padding: 10px 10px 5px 10px; + + .left-text { + font-weight: 400; + font-size: 14px; + color: rgba(219,238,255,0.6); + } + } + + } + + // 复制、删除按钮 + .btn-cus { + display: flex; + background-color: transparent; + align-items: center; + + .btn-image { + height: 20px; + } + } + + .btn-cus:hover { + cursor: pointer; + background-color: #f6f6f6; + } +} + +// 回到底部 +.to-bottom { + position: absolute; + z-index: 1000; + bottom: 0; + right: 50%; +} +</style> diff --git a/src/views/ai/dashboard/zhuanlu/Index.vue b/src/views/ai/dashboard/zhuanlu/Index.vue deleted file mode 100644 index 89bed03..0000000 --- a/src/views/ai/dashboard/zhuanlu/Index.vue +++ /dev/null @@ -1,1138 +0,0 @@ -<template> - <div class="gas-scheduling-container"> - <div class="gas-scheduling-left"> - <div id="mqhsssxx"> - <div class="title"></div> - <div class="data1-item" v-for="(item, index) in mqhsList" :key="`dynamics-${index}`"> - <div class="content"> - <div class="value"> - <span>{{item.value}}</span> <span>{{item.unit}}</span> - </div> - <div class="name"> - {{item.name}} - </div> - </div> - </div> - </div> - <div id="tsxx"> - <div class="title"></div> - <div class="data1-item" v-for="(item, index) in tsxxList" :key="`dynamics-${index}`"> - <div class="content"> - <div class="value"> - <span>{{item.value}}</span> <span>{{item.unit}}</span> - </div> - <div class="name"> - {{item.name}} - </div> - </div> - </div> - </div> - <div id="zlxx"> - <div class="title"></div> - <el-table :data="zlxxList" class="transparent-table"> - <el-table-column prop="name" label="控制器名称" header-class-name="custom-header" width="150"/> - <el-table-column prop="zl1" label="1#转炉" header-class-name="custom-header" /> - <el-table-column prop="zl2" label="2#转炉" header-class-name="custom-header" /> - <el-table-column prop="zl3" label="3#转炉" header-class-name="custom-header" /> - </el-table> - </div> - <div id="mqxhssxx"> - <div class="title"></div> - <div class="data2-item" v-for="(item, index) in mqxhssxxList" :key="`dynamics-${index}`"> - <div class="content2"> - <div class="name"> - {{item.name}} - </div> - <div class="value"> - <span>{{item.value}}</span> <span>{{item.unit}}</span> - </div> - </div> - </div> - </div> - </div> - <!-- 对话列表 --> - <ConversationList - v-show="false" - :active-id="activeConversationId" - ref="conversationListRef" - @on-conversation-click="handleConversationClick" - @on-conversation-clear="handleConversationClear" - @on-conversation-delete="handlerConversationDelete" - /> - <div class="detail-container"> - <!-- 输入框 --> - <div class="input-container"> - <form class="prompt-from"> - <textarea - class="prompt-input" - v-model="prompt" - @keydown="handleSendByKeydown" - @input="handlePromptInput" - @compositionstart="onCompositionstart" - @compositionend="onCompositionend" - placeholder="问我任何问题...(Shift+Enter 换行,按下 Enter 发送)" - ></textarea> - <div class="prompt-btns"> - <div> - <el-switch v-model="enableContext" /> - <span class="ml-5px text-14px text-#8f8f8f">上下文</span> - </div> - <el-button - type="primary" - size="default" - @click="handleSendByButton" - :loading="conversationInProgress" - v-if="conversationInProgress == false" - > - {{ conversationInProgress ? '进行中' : '发送' }} - </el-button> - <el-button - type="danger" - size="default" - @click="stopStream()" - v-if="conversationInProgress == true" - > - 停止 - </el-button> - </div> - </form> - </div> - - <!-- main:消息列表 --> - <el-main class="main-container"> - <div class="title"> - <span>工业能源大模型思考</span> - </div> - <div> - <div class="message-container"> - <!-- 情况一:消息加载中 --> - <MessageLoading v-if="activeMessageListLoading" /> - <!-- 情况二:无聊天对话时 --> -<!-- <MessageNewConversation--> -<!-- v-if="!activeConversation"--> -<!-- @on-new-conversation="handleConversationCreate"--> -<!-- />--> - <!-- 情况三:消息列表为空 --> - <MessageListEmpty - v-if="!activeMessageListLoading && messageList.length === 0 && activeConversation" - @on-prompt="doSendMessage" - /> - <!-- 情况四:消息列表不为空 --> - <MessageList - v-if="!activeMessageListLoading && messageList.length > 0" - ref="messageRef" - :conversation="activeConversation" - :list="messageList" - @on-delete-success="handleMessageDelete" - @on-edit="handleMessageEdit" - @on-refresh="handleMessageRefresh" - /> - </div> - </div> - </el-main> - </div> - - <!-- 更新对话 Form --> - <ConversationUpdateForm - ref="conversationUpdateFormRef" - @success="handleConversationUpdateSuccess" - /> - </div> -</template> - -<script setup lang="ts"> -import { ref, onMounted } from 'vue' -import {ChatConversationApi, ChatConversationVO} from "@/api/ai/chat/conversation"; -import {ChatMessageApi, ChatMessageVO} from "@/api/ai/chat/message"; -import MessageList from '../components/message/MessageList.vue' -import MessageListEmpty from '../components/message/MessageListEmpty.vue' -import MessageLoading from '../components/message/MessageLoading.vue' -import ConversationUpdateForm - from "../components/conversation/ConversationUpdateForm.vue"; -import ConversationList from "../components/conversation/ConversationList.vue"; - -const mqhsList = ref([ - { - name: '单转炉煤气回收流量', - value: 130, - unit: 'km³/h' - }, - { - name: '转炉煤气 O 含量', - value: 618, - unit: '%' - }, - { - name: '转炉煤气 CO 含量', - value: 15, - unit: '%' - }, - { - name: '转炉铁水碳含量', - value: 20, - unit: '%' - }, - { - name: '三通阀信号', - value: 0, - unit: '' - }, - { - name: '单转炉吹氧流量', - value: 400, - unit: 'kNm³/h' - } -]) - -const tsxxList = ref([ - { - name: '各高炉出铁水信号', - value: '进行', - unit: '' - }, - { - name: '各高炉出铁量', - value: 5000, - unit: '吨' - }, - { - name: '各高炉铁水装入鱼雷罐车信号', - value: '进行', - unit: 'm³/h' - }, - { - name: '鱼雷罐车等待信号', - value: '进行', - unit: 'm³/h' - }, - { - name: '铁水倒入铁水包信号', - value: '不进行', - unit: 'm³/h' - }, - { - name: '铁产量计划', - value: 6000, - unit: '吨' - }, -]) - -const zlxxList = ref([ - { - name: '吹炼状态', - zl1: '正在吹炼', - zl2: '正在吹炼', - zl3: '正在吹炼' - }, - { - name: '当前状态持续时间', - zl1: '10min', - zl2: '10min', - zl3: '10min' - }, - { - name: '当前炉吹炼开始时刻', - zl1: '18:40', - zl2: '18:40', - zl3: '18:40' - }, - { - name: '当前炉吹炼结束时刻', - zl1: '18:40', - zl2: '18:40', - zl3: '18:40' - }, - { - name: '前一炉吹炼开始时刻', - zl1: '18:40', - zl2: '18:40', - zl3: '18:40' - }, - { - name: '前一炉吹炼结束时刻', - zl1: '18:40', - zl2: '18:40', - zl3: '18:40' - } -]) - -const mqxhssxxList = ref([ - { - name: '去棒三混合站', - value: 57.1, - unit: 'km³/h' - }, - { - name: '东区掺混 LDG', - value: 49.4, - unit: 'km³/h' - }, - { - name: '去焦化方向', - value: 67.4, - unit: 'm³/h' - }, - { - name: '西区掺混 LDG', - value: 70, - unit: 'm³/h' - }, - { - name: '送 BFG 管网', - value: 50.1, - unit: 'm³/h' - }, - { - name: '去热卷二', - value: 72.2, - unit: 'km³/h' - }, - { - name: '热卷一', - value: 13.9, - unit: 'km³/h' - }, - { - name: '转底炉 1', - value: 7.0, - unit: 'km³/h' - }, - { - name: '超薄带', - value: 67.4, - unit: 'km³/h' - }, - { - name: '转底炉 2', - value: 13.5, - unit: 'km³/h' - }, - { - name: '135MW 1', - value: 45.3, - unit: 'km³/h' - }, - { - name: '135MW 2', - value: 36.2, - unit: 'km³/h' - }, -]) - -const route = useRoute() // 路由 -const message = useMessage() // 消息弹窗 - -// 聊天对话 -const conversationListRef = ref() -const activeConversationId = ref<number | null>(null) // 选中的对话编号 -const activeConversation = ref<ChatConversationVO | null>(null) // 选中的 Conversation -const conversationInProgress = ref(false) // 对话是否正在进行中。目前只有【发送】消息时,会更新为 true,避免切换对话、删除对话等操作 - -// 消息列表 -const messageRef = ref() -const activeMessageList = ref<ChatMessageVO[]>([]) // 选中对话的消息列表 -const activeMessageListLoading = ref<boolean>(false) // activeMessageList 是否正在加载中 -const activeMessageListLoadingTimer = ref<any>() // activeMessageListLoading Timer 定时器。如果加载速度很快,就不进入加载中 -// 消息滚动 -const textSpeed = ref<number>(50) // Typing speed in milliseconds -const textRoleRunning = ref<boolean>(false) // Typing speed in milliseconds - -// 发送消息输入框 -const isComposing = ref(false) // 判断用户是否在输入 -const conversationInAbortController = ref<any>() // 对话进行中 abort 控制器(控制 stream 对话) -const inputTimeout = ref<any>() // 处理输入中回车的定时器 -const prompt = ref<string>() // prompt -const enableContext = ref<boolean>(false) // 是否开启上下文 -// 接收 Stream 消息 -const receiveMessageFullText = ref('') -const receiveMessageDisplayedText = ref('') - -// =========== 【聊天对话】相关 =========== - -/** 获取对话信息 */ -const getConversation = async (id: number | null) => { - if (!id) { - return - } - const conversation: ChatConversationVO = await ChatConversationApi.getChatConversationMy(id) - if (!conversation) { - return - } - activeConversation.value = conversation - activeConversationId.value = conversation.id -} - -/** - * 点击某个对话 - * - * @param conversation 选中的对话 - * @return 是否切换成功 - */ -const handleConversationClick = async (conversation: ChatConversationVO) => { - // 对话进行中,不允许切换 - if (conversationInProgress.value) { - message.alert('对话中,不允许切换!') - return false - } - - // 更新选中的对话 id - activeConversationId.value = conversation.id - activeConversation.value = conversation - // 刷新 message 列表 - await getMessageList() - // 滚动底部 - scrollToBottom(true) - // 清空输入框 - prompt.value = '' - return true -} - -/** 删除某个对话*/ -const handlerConversationDelete = async (delConversation: ChatConversationVO) => { - // 删除的对话如果是当前选中的,那么就重置 - if (activeConversationId.value === delConversation.id) { - await handleConversationClear() - } -} -/** 清空选中的对话 */ -const handleConversationClear = async () => { - // 对话进行中,不允许切换 - if (conversationInProgress.value) { - message.alert('对话中,不允许切换!') - return false - } - activeConversationId.value = null - activeConversation.value = null - activeMessageList.value = [] -} - -/** 修改聊天对话 */ -const conversationUpdateFormRef = ref() -const openChatConversationUpdateForm = async () => { - conversationUpdateFormRef.value.open(activeConversationId.value) -} -const handleConversationUpdateSuccess = async () => { - // 对话更新成功,刷新最新信息 - await getConversation(activeConversationId.value) -} - -// =========== 【消息列表】相关 =========== - -/** 获取消息 message 列表 */ -const getMessageList = async () => { - try { - if (activeConversationId.value === null) { - return - } - // Timer 定时器,如果加载速度很快,就不进入加载中 - activeMessageListLoadingTimer.value = setTimeout(() => { - activeMessageListLoading.value = true - }, 60) - - // 获取消息列表 - activeMessageList.value = await ChatMessageApi.getEnergyChatMessageListByConversationId( - activeConversationId.value - ) - if(activeMessageList.value.length != 0) { - console.log(22222222) - console.log(activeMessageList.value[0].content) - prompt.value = activeMessageList.value[0].content - } - - // 滚动到最下面 - await nextTick() - await scrollToBottom() - } finally { - // time 定时器,如果加载速度很快,就不进入加载中 - if (activeMessageListLoadingTimer.value) { - clearTimeout(activeMessageListLoadingTimer.value) - } - // 加载结束 - activeMessageListLoading.value = false - } -} - -/** - * 消息列表 - * - * 和 {@link #getMessageList()} 的差异是,把 systemMessage 考虑进去 - */ -const messageList = computed(() => { - if (activeMessageList.value.length > 0) { - return activeMessageList.value - } - // 没有消息时,如果有 systemMessage 则展示它 - if (activeConversation.value?.systemMessage) { - return [ - { - id: 0, - type: 'system', - content: activeConversation.value.systemMessage - } - ] - } - return [] -}) - -/** 处理删除 message 消息 */ -const handleMessageDelete = () => { - if (conversationInProgress.value) { - message.alert('回答中,不能删除!') - return - } - // 刷新 message 列表 - getMessageList() -} - -/** 处理 message 清空 */ -const handlerMessageClear = async () => { - if (!activeConversationId.value) { - return - } - try { - // 确认提示 - // await message.delConfirm('确认清空历史对话结果?') - // 清空对话 - await ChatMessageApi.deleteEnergyByConversationId(activeConversationId.value) - // 刷新 message 列表 - activeMessageList.value = [] - } catch {} -} - -/** 回到 message 列表的顶部 */ -const handleGoTopMessage = () => { - messageRef.value.handlerGoTop() -} - -// =========== 【发送消息】相关 =========== - -/** 处理来自 keydown 的发送消息 */ -const handleSendByKeydown = async (event) => { - // 判断用户是否在输入 - if (isComposing.value) { - return - } - // 进行中不允许发送 - if (conversationInProgress.value) { - return - } - const content = prompt.value?.trim() as string - if (event.key === 'Enter') { - if (event.shiftKey) { - // 插入换行 - prompt.value += '\r\n' - event.preventDefault() // 防止默认的换行行为 - } else { - // 发送消息 - await doSendMessage(content) - event.preventDefault() // 防止默认的提交行为 - } - } -} - -/** 处理来自【发送】按钮的发送消息 */ -const handleSendByButton = () => { - doSendMessage(prompt.value?.trim() as string) -} - -/** 处理 prompt 输入变化 */ -const handlePromptInput = (event) => { - // 非输入法 输入设置为 true - if (!isComposing.value) { - // 回车 event data 是 null - if (event.data == null) { - return - } - isComposing.value = true - } - // 清理定时器 - if (inputTimeout.value) { - clearTimeout(inputTimeout.value) - } - // 重置定时器 - inputTimeout.value = setTimeout(() => { - isComposing.value = false - }, 400) -} -// TODO @芋艿:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑 -const onCompositionstart = () => { - isComposing.value = true -} -const onCompositionend = () => { - // console.log('输入结束...') - setTimeout(() => { - isComposing.value = false - }, 200) -} - -/** 真正执行【发送】消息操作 */ -const doSendMessage = async (content: string) => { - // 校验 - if (content.length < 1) { - message.error('发送失败,原因:内容为空!') - return - } - if (activeConversationId.value == null) { - message.error('还没创建对话,不能发送!') - return - } - // 执行发送 - await doSendMessageStream({ - conversationId: activeConversationId.value, - content: content - } as ChatMessageVO) -} - -/** 真正执行【发送】消息操作 */ -const doSendMessageStream = async (userMessage: ChatMessageVO) => { - // 创建 AbortController 实例,以便中止请求 - conversationInAbortController.value = new AbortController() - // 标记对话进行中 - conversationInProgress.value = true - // 设置为空 - // receiveMessageFullText.value = '' - - try { - // 1.0 每次发送消息前先将消息记录chat message清空 - await handlerMessageClear() - // 1.1 先添加两个假数据,等 stream 返回再替换 - activeMessageList.value.push({ - id: -1, - conversationId: activeConversationId.value, - type: 'user', - content: userMessage.content, - createTime: new Date() - } as ChatMessageVO) - activeMessageList.value.push({ - id: -2, - conversationId: activeConversationId.value, - type: 'assistant', - content: '思考中...', - createTime: new Date() - } as ChatMessageVO) - // 1.2 滚动到最下面 - await nextTick() - await scrollToBottom() // 底部 - // 1.3 开始滚动 - textRoll() - - // 2. 发送 event stream - let isFirstChunk = true // 是否是第一个 chunk 消息段 - await ChatMessageApi.sendEnergyChatMessageStream( - userMessage.conversationId, - userMessage.content, - conversationInAbortController.value, - enableContext.value, - async (res) => { - const { code, data, msg } = JSON.parse(res.data) - if (code !== 0) { - message.alert(`对话异常! ${msg}`) - return - } - - // 如果内容为空,就不处理。 - if (data.receive.content === '') { - return - } - // 首次返回需要添加一个 message 到页面,后面的都是更新 - if (isFirstChunk) { - isFirstChunk = false - // 弹出两个假数据 - activeMessageList.value.pop() - activeMessageList.value.pop() - // 更新返回的数据 - activeMessageList.value.push(data.send) - activeMessageList.value.push(data.receive) - } - // debugger - receiveMessageFullText.value = receiveMessageFullText.value + data.receive.content - // 滚动到最下面 - await scrollToBottom() - }, - (error) => { - message.alert(`对话异常! ${error}`) - stopStream() - }, - () => { - stopStream() - } - ) - } catch {} -} - -/** 停止 stream 流式调用 */ -const stopStream = async () => { - // tip:如果 stream 进行中的 message,就需要调用 controller 结束 - if (conversationInAbortController.value) { - conversationInAbortController.value.abort() - } - // 设置为 false - conversationInProgress.value = false -} - -/** 编辑 message:设置为 prompt,可以再次编辑 */ -const handleMessageEdit = (message: ChatMessageVO) => { - prompt.value = message.content -} - -/** 刷新 message:基于指定消息,再次发起对话 */ -const handleMessageRefresh = (message: ChatMessageVO) => { - doSendMessage(message.content) -} - -// ============== 【消息滚动】相关 ============= - -/** 滚动到 message 底部 */ -const scrollToBottom = async (isIgnore?: boolean) => { - await nextTick() - if (messageRef.value) { - messageRef.value.scrollToBottom(isIgnore) - } -} - -/** 自提滚动效果 */ -const textRoll = async () => { - let index = 0 - try { - // 只能执行一次 - if (textRoleRunning.value) { - return - } - // 设置状态 - textRoleRunning.value = true - receiveMessageDisplayedText.value = '' - const task = async () => { - // 调整速度 - const diff = - (receiveMessageFullText.value.length - receiveMessageDisplayedText.value.length) / 10 - if (diff > 5) { - textSpeed.value = 10 - } else if (diff > 2) { - textSpeed.value = 30 - } else if (diff > 1.5) { - textSpeed.value = 50 - } else { - textSpeed.value = 100 - } - // 对话结束,就按 30 的速度 - if (!conversationInProgress.value) { - textSpeed.value = 10 - } - - if (index < receiveMessageFullText.value.length) { - receiveMessageDisplayedText.value += receiveMessageFullText.value[index] - index++ - - // 更新 message - const lastMessage = activeMessageList.value[activeMessageList.value.length - 1] - lastMessage.content = receiveMessageDisplayedText.value - // 滚动到住下面 - await scrollToBottom() - // 重新设置任务 - timer = setTimeout(task, textSpeed.value) - } else { - // 不是对话中可以结束 - if (!conversationInProgress.value) { - textRoleRunning.value = false - clearTimeout(timer) - } else { - // 重新设置任务 - timer = setTimeout(task, textSpeed.value) - } - } - } - let timer = setTimeout(task, textSpeed.value) - } catch {} -} - -/** 初始化 **/ -onMounted(async () => { - // 如果有 conversationId 参数,则默认选中 - if (route.query.conversationId) { - const id = route.query.conversationId as unknown as number - activeConversationId.value = id - await getConversation(id) - } - - // 获取列表数据 - activeMessageListLoading.value = true - await getMessageList() -}) - -</script> - -<style lang="scss" scoped> -.gas-scheduling-container { - display: flex; - font-family: Microsoft YaHei, Microsoft YaHei; - /* 背景层容器 */ - &::before { - content: ''; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: -1; /* 置于内容层下方 */ - background: - url("@/assets/ai/zhuanlu/bg.png") center/cover no-repeat, - linear-gradient(to bottom, #0a1633dd, #0a1633dd); /* 叠加深色遮罩 */ - pointer-events: none; /* 防止遮挡交互 */ - } - .gas-scheduling-left { - width: 22%; - height: 89%; - margin-left: 32px; - margin-top: 30px; - z-index: 1; - background-color: rgba(0, 0, 0, 0); /* 透明背景 */ - .data1-item { - height: 41px; - width: 182px; - display: inline-block; - margin: 8px 10px ; - background: url("@/assets/ai/zhuanlu/data_bg1.png"); - } - .data2-item { - height: 30px; - width: 192px; - display: inline-block; - margin: 6px 8px; - background: url("@/assets/ai/zhuanlu/data_bg2.png"); - } - .content { - margin-left: 16px; - .value { - span:nth-child(1){ - height: 19px; - font-weight: bold; - font-size: 16px; - color: #FFAE81; - line-height: 19px; - } - span:nth-child(2) { - height: 16px; - font-weight: 400; - font-size: 12px; - color: #C7E7FF; - } - } - .name { - height: 16px; - font-weight: 400; - font-size: 12px; - color: #C7E7FF; - } - } - .content2 { - display: flex; - width: 192px; - margin-left: 10px; - .name { - width: 95px; - height: 18px; - font-weight: 400; - font-size: 14px; - color: #C7E7FF; - } - .value { - margin-left: auto; - margin-right: 5px; - span:nth-child(1){ - height: 15px; - font-weight: bold; - font-size: 15px; - color: #FFAE81; - line-height: 15px; - } - span:nth-child(2) { - height: 15px; - font-weight: 400; - font-size: 12px; - color: #C7E7FF; - } - } - } - #mqhsssxx { - .title { - height: 30px; - background: - url("@/assets/ai/zhuanlu/mqhsssxx_title.png") center/cover no-repeat; /* 叠加深色遮罩 */ - } - } - #tsxx { - .title { - height: 30px; - background: - url("@/assets/ai/zhuanlu/tsxx_title.png") center/cover no-repeat; /* 叠加深色遮罩 */ - } - } - #zlxx { - .title { - height: 30px; - background: - url("@/assets/ai/zhuanlu/zlxx_title.png") center/cover no-repeat; /* 叠加深色遮罩 */ - } - :deep(.el-table){ - font-weight: 400; - font-size: 14px; - color: #DBEEFF; - text-align: left; - } - .transparent-table{ - margin-top: 14px; - } - /* 设置列头背景图片 */ - :deep(.el-table .el-table__inner-wrapper .el-table__header-wrapper) { - background: - url("@/assets/ai/zhuanlu/table_header_bg.png") center/cover no-repeat !important; /* 叠加深色遮罩 */ - } - :deep(.el-table .even-row){ - background-color: rgba(0,194,255,0.5); - } - :deep(.el-table .odd-row) { - background-color: rgba(0,194,255,0.8); - } - :deep(.current-row>td){ - background-color: rgba(16,198,255,0.2); - } - :deep(.el-table th) { - color: #8FD6FE; - background: linear-gradient( 180deg, rgba(16,198,255,0) 0%, rgba(17,198,255,0.14) 100%); - border: 1px solid rgba(16,198,255,0.2); - } - :deep(.el-table thead){ - color: #8FD6FE; - font-weight: 500; - border-radius: 4px 4px 4px 4px; - } - :deep(.el-table tr) { - color: #8FD6FE; - background: rgba(2, 16, 36, 0.94); - border-radius: 4px 4px 4px 4px; - } - :deep(.el-table td, - .building-top .el-table th.is-leaf) { - border-bottom: 0px solid #FAFAFB; - } - :deep(.el-table .el-table__inner-wrapper:before) { - background-color: transparent !important; - } - } - #mqxhssxx { - .title { - height: 30px; - background: - url("@/assets/ai/zhuanlu/mqxhssxx_title.png") center/cover no-repeat; /* 叠加深色遮罩 */ - } - } - } - - // 头部 - .detail-container { - width: 876px; - height: 885px; - margin-left: 55px; - margin-top: 100px; - background-color: rgba(0, 0, 0, 0); /* 透明背景 */ - z-index: 1; - .header { - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - box-shadow: 0 0 0 0 #dcdfe6; - - .title { - font-size: 18px; - font-weight: bold; - } - - .btns { - display: flex; - width: 300px; - flex-direction: row; - justify-content: flex-end; - //justify-content: space-between; - - .btn { - padding: 10px; - } - } - } - } - - .conversation-container { - z-index: 1; - position: relative; - display: flex; - flex-direction: column; - justify-content: space-between; - padding: 10px 10px 0; - .btn-new-conversation { - padding: 18px 0; - } - - .search-input { - margin-top: 20px; - } - - .conversation-list { - margin-top: 20px; - - .conversation { - display: flex; - flex-direction: row; - justify-content: space-between; - flex: 1; - padding: 0 5px; - margin-top: 10px; - cursor: pointer; - border-radius: 5px; - align-items: center; - line-height: 30px; - - &.active { - background-color: #e6e6e6; - - .button { - display: inline-block; - } - } - - .title-wrapper { - display: flex; - flex-direction: row; - align-items: center; - } - - .title { - padding: 5px 10px; - max-width: 220px; - font-size: 14px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - - .avatar { - width: 28px; - height: 28px; - display: flex; - flex-direction: row; - justify-items: center; - } - - // 对话编辑、删除 - .button-wrapper { - right: 2px; - display: flex; - flex-direction: row; - justify-items: center; - color: #606266; - .el-icon { - margin-right: 5px; - } - } - } - } - } - - // main 容器 - .main-container { - margin: 0; - padding: 0; - position: relative; - height: 100%; - width: 100%; - - .message-container { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - overflow-y: hidden; - padding: 0; - margin: 0; - } - .title { - background: url("@/assets/ai/zhuanlu/think_bg.png") center/cover no-repeat; - width: auto; - height: 30px; - font-weight: 400; - font-size: 14px; - color: #8FD6FE; - text-align: left; - font-style: normal; - text-transform: none; - span { - margin-left: 30px; - } - } - } - - // 输入框 - .input-container { - display: flex; - flex-direction: column; - height: auto; - margin: 0; - padding: 0; - overflow-y: auto; /* 垂直方向溢出时显示滚动条 */ - overflow-x: hidden; /* 水平方向隐藏滚动条 */ - /* Firefox */ - scrollbar-width: thin; - scrollbar-color: rgba(0, 0, 0, 0.15) transparent; - - /* WebKit */ - &::-webkit-scrollbar { - width: 6px; - background: transparent; - } - &::-webkit-scrollbar-thumb { - border-radius: 4px; - background: rgba(0, 0, 0, 0.15); - transition: background 0.3s; - &:hover { background: rgba(0, 0, 0, 0.25); } - } - - .prompt-from { - display: flex; - flex-direction: column; - margin: 10px 20px 20px 20px; - padding: 9px 10px; - width: 855px; - height: 225px; - background: rgba(115,196,255,0.05); - border-radius: 4px 4px 4px 4px; - border: 1px solid #73C4FF; - } - - .prompt-input { - width: 851px; - height: 168px; - font-weight: 400; - font-size: 14px; - background-color: rgba(219,238,255,0); - line-height: 21px; - text-align: left; - font-style: normal; - text-transform: none; - border: 0; - color: rgba(219,238,255,0.6); - } - - .prompt-input:focus { - outline: none; - } - - .prompt-btns { - display: flex; - justify-content: space-between; - padding-bottom: 0; - padding-top: 5px; - } - } -} -</style> diff --git a/src/views/ai/dashboard/zhuanlu/index.vue b/src/views/ai/dashboard/zhuanlu/index.vue new file mode 100644 index 0000000..0d0be46 --- /dev/null +++ b/src/views/ai/dashboard/zhuanlu/index.vue @@ -0,0 +1,2347 @@ +<template> + <div class="gas-scheduling-container"> + <el-button size="small" class="fullscreen-btn" @click="toggleFullscreen"> + <Icon + class="is-hover mr-12px cursor-pointer" + :icon="isFullscreen ? 'radix-icons:exit-full-screen' : 'radix-icons:enter-full-screen'" + color="#8FD6FE" + hover-color="var(--el-color-primary)" + /> + {{ isFullscreen ? '退出全屏' : '全屏' }} + </el-button> + <div class="gas-scheduling-left"> + <div id="mqhsssxx"> + <div class="title"></div> + <div class="data1-item" v-for="(item, index) in mqhsList" :key="`dynamics-${index}`"> + <div class="content"> + <div class="value"> + <span>{{item.value}}</span> <span>{{item.unit}}</span> + </div> + <div class="name"> + {{item.name}} + </div> + </div> + </div> + </div> + <div id="tsxx"> + <div class="title"></div> + <div class="data1-item" v-for="(item, index) in tsxxList" :key="`dynamics-${index}`"> + <div class="content"> + <div class="value"> + <span>{{item.value}}</span> <span>{{item.unit}}</span> + </div> + <div class="name"> + {{item.name}} + </div> + </div> + </div> + </div> + <div id="zlxx"> + <div class="title"></div> + <el-table :data="zlxxList" class="transparent-table"> + <el-table-column prop="name" label="控制器名称" header-class-name="custom-header" width="150"/> + <el-table-column prop="zl1" label="1#转炉" header-class-name="custom-header" /> + <el-table-column prop="zl2" label="2#转炉" header-class-name="custom-header" /> + <el-table-column prop="zl3" label="3#转炉" header-class-name="custom-header" /> + </el-table> + </div> + <div id="mqxhssxx"> + <div class="title"></div> + <div class="data2-item" v-for="(item, index) in mqxhssxxList" :key="`dynamics-${index}`"> + <div class="content2"> + <div class="name"> + {{item.name}} + </div> + <div class="value"> + <span>{{item.value}}</span> <span>{{item.unit}}</span> + </div> + </div> + </div> + </div> + </div> + + <div class="gas-scheduling-center"> + <div class="mode-switch"> + <el-radio-group v-model="tabPosition" class="custom-radio-group"> + <el-radio-button label="model">大模型模式</el-radio-button> + <el-radio-button label="conversation">对话模式</el-radio-button> + </el-radio-group> + </div> + + <div v-if="tabPosition === 'model'"> + <!-- 对话列表 --> + <ConversationList + v-show="false" + :active-id="activeConversationId" + ref="conversationListRef" + @on-conversation-click="handleConversationClick" + @on-conversation-clear="handleConversationClear" + @on-conversation-delete="handlerConversationDelete" + /> + <div class="detail-container"> + <!-- 输入框 --> + <div class="input-container"> + <form class="prompt-from"> + <textarea + class="prompt-input" + v-model="prompt" + @keydown="handleSendByKeydown" + @input="handlePromptInput" + @compositionstart="onCompositionstart" + @compositionend="onCompositionend" + placeholder="问我任何问题...(Shift+Enter 换行,按下 Enter 发送)" + ></textarea> + <div class="prompt-btns"> + <div class="content"> + <el-button + :class="{ 'active-button': enableContext }" + @click="enableContext = !enableContext" + > + <el-icon class="content-icon" /> + 上下文 + </el-button> + </div> + <div class="message"> + <el-button + type="primary" + size="default" + @click="handleSendByButton" + :loading="conversationInProgress" + v-if="conversationInProgress == false" + > + {{ conversationInProgress ? '进行中' : '发消息' }} + </el-button> + <el-button + type="danger" + size="default" + @click="stopStream()" + v-if="conversationInProgress == true" + > + 停止 + </el-button> + </div> + </div> + </form> + </div> + + <!-- main:消息列表 --> + <el-main class="main-container"> + <div class="title"> + <span>工业能源大模型思考</span> + </div> + <div> + <div class="message-container"> + <!-- 情况一:消息加载中 --> + <MessageLoading v-if="activeMessageListLoading" /> + <!-- 情况二:消息列表为空 --> + <MessageListEmpty + v-if="!activeMessageListLoading && messageList.length === 0 && activeConversation" + @on-prompt="doSendMessage" + /> + <!-- 情况三:消息列表不为空 --> + <ModelMessageList + v-if="!activeMessageListLoading && messageList.length > 0" + ref="messageRef" + :conversation="activeConversation" + :list="messageList" + @on-delete-success="handleMessageDelete" + @on-edit="handleMessageEdit" + @on-refresh="handleMessageRefresh" + /> + </div> + </div> + </el-main> + <!-- main:调度推理结论 --> + <div class="result-container-title"> + <span>调度推理结论</span><el-button @click="openHistoryMessage" size="small" class="history-button" :icon="ArrowUpBold">历史建议</el-button> + </div> + <el-main class="result-container"> + <div class="result"> + <textarea class="result-content" v-model="ddtlResult"></textarea> + </div> + </el-main> + </div> + <!-- 历史建议 --> + <HistoryMessageDialog + ref="historyMessageRef" + :conversation="activeConversation" + /> + </div> + + <div v-else> + <NormalConversation /> + </div> + </div> + + <div class="gas-scheduling-right"> + <div id="ldghslyc"> + <div class="title"></div> + <div ref="LDGHSLYCEhartContainer" style="width: 100%; height: 180px"></div> + </div> + <div id="ldggrqsyc"> + <div class="title"></div> + <div ref="LDGGRYCEhartContainer" style="width: 100%; height: 180px"></div> + </div> + <div id="mqhsjhxx"> + <div class="title"></div> + <div class="time-content" v-for="(item, index) in mqhsjhTimeList" :key="`dynamics-${index}`"> + <div class="time-content-item"> + <div class="name"> + <span>{{item.name}}</span> + </div> + <div class="time"> + <div class="in-pot"></div><div class="in">装入{{item.inTime}}</div> + <div class="out-pot"></div><div class="out">结束{{item.outTime}}</div> + </div> + </div> + </div> + <div class="data2-item" v-for="(item, index) in mqhsjhxxList" :key="`dynamics-${index}`"> + <div class="content"> + <div class="name"> + {{item.name}} + </div> + <div class="value"> + <span>{{item.value}}</span> <span>{{item.unit}}</span> + </div> + </div> + </div> + </div> + <div id="scmbyyxzb"> + <div class="title"></div> + <div class="little-title">生产目标/班</div> + <div class="data3-item" v-for="(item, index) in scmbList" :key="`dynamics-${index}`"> + <div class="content2"> + <div class="name"> + {{item.name}} + </div> + <el-progress + :percentage="percentage(item)" + :stroke-width="12" + :text-inside="true" + :color="customColor" + :show-text="false" + /> + <div class="value"> + <span>{{item.current}}/{{item.total}}</span> + </div> + <div class="value-content"> + <span>已吹炼/总炉数</span> + </div> + </div> + </div> + <div class="little-title">运行指标/天</div> + <div class="zb-content"> + <div class="item left-label"></div> + <div class="item data4-item" v-for="(item, index) in yxzbList" :key="`dynamics-${index}`"> + <div class="content"> + <div class="value"> + <span>{{item.value}}</span> <span>{{item.unit}}</span> + </div> + <div class="name"> + {{item.name}} + </div> + </div> + </div> + <div class="item right-label"></div> + </div> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { ref, onMounted, reactive } from 'vue' +import {ChatConversationApi, ChatConversationVO} from "@/api/ai/chat/conversation"; +import {ChatMessageApi, ChatMessageVO} from "@/api/ai/chat/message"; +import ModelMessageList from '../components/message/ModelMessageList.vue' +import NormalConversation from '../components/conversation/CommonConversation.vue' +import MessageListEmpty from '../components/message/MessageListEmpty.vue' +import MessageLoading from '../components/message/MessageLoading.vue' +import ConversationList from "../components/conversation/ConversationList.vue"; +import HistoryMessageDialog from "../components/message/HistoryMessageDialog.vue" +import * as echarts from "echarts"; +import {formatToDateTime} from "@/utils/dateUtil"; +import {refreshToken} from "@/api/login"; +import {round} from "lodash-es"; +import {ArrowUpBold} from "@element-plus/icons-vue"; +import * as authUtil from "@/utils/auth"; + +const mqhsList = ref([ + { + name: '单转炉煤气回收流量', + value: 130, + unit: 'km³/h' + }, + { + name: '转炉煤气 O 含量', + value: 618, + unit: '%' + }, + { + name: '转炉煤气 CO 含量', + value: 15, + unit: '%' + }, + { + name: '转炉铁水碳含量', + value: 20, + unit: '%' + }, + { + name: '三通阀信号', + value: 0, + unit: '' + }, + { + name: '单转炉吹氧流量', + value: 400, + unit: 'kNm³/h' + } +]) + +const tsxxList = ref([ + { + name: '各高炉出铁水信号', + value: '进行', + unit: '' + }, + { + name: '各高炉出铁量', + value: 5000, + unit: '吨' + }, + { + name: '各高炉铁水装入鱼雷罐车信号', + value: '进行', + unit: 'm³/h' + }, + { + name: '鱼雷罐车等待信号', + value: '进行', + unit: 'm³/h' + }, + { + name: '铁水倒入铁水包信号', + value: '不进行', + unit: 'm³/h' + }, + { + name: '铁产量计划', + value: 6000, + unit: '吨' + }, +]) + +const zlxxList = ref([ + { + name: '吹炼状态', + zl1: '正在吹炼', + zl2: '正在吹炼', + zl3: '正在吹炼' + }, + { + name: '当前状态持续时间', + zl1: '10min', + zl2: '10min', + zl3: '10min' + }, + { + name: '当前炉吹炼开始时刻', + zl1: '18:40', + zl2: '18:40', + zl3: '18:40' + }, + { + name: '当前炉吹炼结束时刻', + zl1: '18:40', + zl2: '18:40', + zl3: '18:40' + }, + { + name: '前一炉吹炼开始时刻', + zl1: '18:40', + zl2: '18:40', + zl3: '18:40' + }, + { + name: '前一炉吹炼结束时刻', + zl1: '18:40', + zl2: '18:40', + zl3: '18:40' + } +]) + +const mqxhssxxList = ref([ + { + name: '去棒三混合站', + value: 57.1, + unit: 'km³/h' + }, + { + name: '东区掺混 LDG', + value: 49.4, + unit: 'km³/h' + }, + { + name: '去焦化方向', + value: 67.4, + unit: 'm³/h' + }, + { + name: '西区掺混 LDG', + value: 70, + unit: 'm³/h' + }, + { + name: '送 BFG 管网', + value: 50.1, + unit: 'm³/h' + }, + { + name: '去热卷二', + value: 72.2, + unit: 'km³/h' + }, + { + name: '热卷一', + value: 13.9, + unit: 'km³/h' + }, + { + name: '转底炉 1', + value: 7.0, + unit: 'km³/h' + }, + { + name: '超薄带', + value: 67.4, + unit: 'km³/h' + }, + { + name: '转底炉 2', + value: 13.5, + unit: 'km³/h' + }, + { + name: '135MW 1', + value: 45.3, + unit: 'km³/h' + }, + { + name: '135MW 2', + value: 36.2, + unit: 'km³/h' + }, +]) + +const mqhsjhxxList = ref([ + { + name: '转炉总炉数\n' + + '日计划', + value: 567, + unit: '炉' + }, + { + name: '转炉入炉铁水量\n' + + '日计划', + value: 200, + unit: '吨' + }, + { + name: '转炉检修计划', + value: '未进行', + unit: '' + }, + { + name: '钢产量日计划', + value: 300, + unit: '吨' + }, + { + name: '转炉加入废钢总量', + value: 500, + unit: '吨' + }, + { + name: '转炉实绩钢产量', + value: 100, + unit: '吨' + } +]) + +const mqhsjhTimeList = ref([ + { + name: '兑铁', + inTime: '04-23 03:19', + outTime: '04-28 14:54', + }, + { + name: '吹炼', + inTime: '04-23 03:19', + outTime: '04-28 14:54', + }, + { + name: '出钢', + inTime: '04-23 03:19', + outTime: '04-28 14:54', + } +]) + +const scmbList = ref([ + { + id: 1, + name: '1#转炉', + current: 20, + total: 30 + }, + { + id: 2, + name: '2#转炉', + current: 25, + total: 100 + }, + { + id: 3, + name: '3#转炉', + current: 4, + total: 29 + } +]) + +// 自定义进度条颜色(可选) +const customColor = '#409EFF'; + +const percentage = (item) => { + return Math.round((item.current / item.total) * 100); +}; + +const yxzbList = ref([ + { + name: '昨日LDG拒收时间', + value: 0.00, + unit: 'min' + }, + { + name: '昨日吨钢回收量', + value: 86.08, + unit: 'm³/t' + },{ + name: '昨日LDG混入累积量', + value: 2687.25, + unit: 'km³' + } + +]) + +const ddtlResult = ref('') + +const route = useRoute() // 路由 +const message = useMessage() // 消息弹窗 + +// 聊天对话 +const conversationListRef = ref() +const activeConversationId = ref<number | null>(null) // 选中的对话编号 +const activeConversation = ref<ChatConversationVO | null>(null) // 选中的 Conversation +const conversationInProgress = ref(false) // 对话是否正在进行中。目前只有【发送】消息时,会更新为 true,避免切换对话、删除对话等操作 + +// 消息列表 +const messageRef = ref() +const activeMessageList = ref<ChatMessageVO[]>([]) // 选中对话的消息列表 +const activeHistoryMessageList = ref<ChatMessageVO[]>([]) // 历史建议列表 +const activeMessageListLoading = ref<boolean>(false) // activeMessageList 是否正在加载中 +const activeMessageListLoadingTimer = ref<any>() // activeMessageListLoading Timer 定时器。如果加载速度很快,就不进入加载中 +// 消息滚动 +const textSpeed = ref<number>(50) // Typing speed in milliseconds +const textRoleRunning = ref<boolean>(false) // Typing speed in milliseconds + +// 发送消息输入框 +const isComposing = ref(false) // 判断用户是否在输入 +const conversationInAbortController = ref<any>() // 对话进行中 abort 控制器(控制 stream 对话) +const inputTimeout = ref<any>() // 处理输入中回车的定时器 +const prompt = ref<string>() // prompt +const enableContext = ref<boolean>(false) // 是否开启上下文 +// 接收 Stream 消息 +const receiveMessageFullText = ref('') +const receiveMessageDisplayedText = ref('') + +const tabPosition = ref('model') + +// 模型数据 +const modelData = ref() + +/** 历史建议 */ +const historyMessageRef = ref() +const openHistoryMessage = async () => { + // 刷新 message 列表 + await getHistoryMessageList() + historyMessageRef.value.open(activeHistoryMessageList.value, activeConversation.value) +} + +// =========== 【聊天对话】相关 =========== + +/** 获取对话信息 */ +const getConversation = async (id: number | null) => { + if (!id) { + return + } + const conversation: ChatConversationVO = await ChatConversationApi.getChatConversationMy(id) + if (!conversation) { + return + } + activeConversation.value = conversation + activeConversationId.value = conversation.id +} + +/** + * 点击某个对话 + * + * @param conversation 选中的对话 + * @return 是否切换成功 + */ +const handleConversationClick = async (conversation: ChatConversationVO) => { + // 对话进行中,不允许切换 + if (conversationInProgress.value) { + message.alert('对话中,不允许切换!') + return false + } + + // 更新选中的对话 id + activeConversationId.value = conversation.id + activeConversation.value = conversation + // 刷新 message 列表 + await getMessageList() + // 滚动底部 + scrollToBottom(true) + // 清空输入框 + // prompt.value = '' + return true +} + +/** 删除某个对话*/ +const handlerConversationDelete = async (delConversation: ChatConversationVO) => { + // 删除的对话如果是当前选中的,那么就重置 + if (activeConversationId.value === delConversation.id) { + await handleConversationClear() + } +} +/** 清空选中的对话 */ +const handleConversationClear = async () => { + // 对话进行中,不允许切换 + if (conversationInProgress.value) { + message.alert('对话中,不允许切换!') + return false + } + activeConversationId.value = null + activeConversation.value = null + activeMessageList.value = [] +} + +// =========== 【消息列表】相关 =========== + +/** 获取消息 message 列表 */ +const getMessageList = async () => { + try { + if (activeConversationId.value === null) { + return + } + // Timer 定时器,如果加载速度很快,就不进入加载中 + activeMessageListLoadingTimer.value = setTimeout(() => { + activeMessageListLoading.value = true + }, 60) + + // 获取消息列表 + activeMessageList.value = await ChatMessageApi.getEnergyChatMessageListByConversationId( + activeConversationId.value + ) + if(activeMessageList.value.length != 0) { + prompt.value = activeMessageList.value[0].content + } + + // 滚动到最下面 + await nextTick() + await scrollToBottom() + } finally { + // time 定时器,如果加载速度很快,就不进入加载中 + if (activeMessageListLoadingTimer.value) { + clearTimeout(activeMessageListLoadingTimer.value) + } + // 加载结束 + activeMessageListLoading.value = false + } +} + +/** 获取消息 message 列表 */ +const getHistoryMessageList = async () => { + if (activeConversationId.value === null) { + return + } + // 获取消息列表 + activeHistoryMessageList.value = await ChatMessageApi.getChatMessageListByConversationId( + activeConversationId.value + ) + if (activeHistoryMessageList.value.length > 0) { + activeHistoryMessageList.value.forEach((message: ChatMessageVO) => { + if(message.type != 'user') { + dealResult(message) + } + }) + return activeHistoryMessageList.value + } +} +//处理调度推理结论 +const dealResult = (message: any) => { + const spliceText = message.content.includes("总结:") ? "总结:" : "结论:"; + const regex = new RegExp('(\\n*)([\\s\\S]*?)(\\n*)' + spliceText + '([\\s\\S]*)'); + const match = message.content.match(regex); + if(match) { + message.thinking = match[2]; + message.conclusion = match[4] + } + return message +} + + +/** + * 消息列表 + * + * 和 {@link #getMessageList()} 的差异是,把 systemMessage 考虑进去 + */ +const messageList = computed(() => { + if (activeMessageList.value.length > 0) { + activeMessageList.value[1].thinking = dealResultAndData(activeMessageList.value[1].content) + console.log(activeMessageList.value) + return activeMessageList.value + } + // 没有消息时,如果有 systemMessage 则展示它 + if (activeConversation.value?.systemMessage) { + return [ + { + id: 0, + type: 'system', + content: activeConversation.value.systemMessage + } + ] + } + return [] +}) + +//处理调度推理结论及数据 +const dealResultAndData = (content: string) => { + const spliceText = content.includes("总结:") ? "总结:" : "结论:"; +// 创建同时捕获前后内容的正则表达式 + const regex = new RegExp(`^([\\s\\S]*?)${spliceText}([\\s\\S]*)$`); + const match = content.match(regex); + +// 获取前面段落(优先返回匹配结果,若无匹配返回全文) + content = match ? match[1].trim() : content; +// 已存在的后面段落获取方式 + const result = match ? match[2].trim() : ''; + ddtlResult.value = result + const dataRegex = /转炉煤气回收情况:\s*((?:.*?)(?=\n\d\.|\n|$))/s; + const dataMatch = content.match(dataRegex); + const dataContent = dataMatch ? dataMatch[1] : ''; + modelData.value = extractRecoveryDetails(dataContent, 78, 90); + if(modelData.value.schedule.length === 3) { + initLDGHSLYCChart() + } + initLDGGRQSYCChart() + return content +} + +const extractRecoveryDetails = (text, consume, gui, totalMinutes = 60) => { + // 正则表达式匹配转炉数据块 + const furnaceBlocks = Array.from(text.matchAll(/(\d+#转炉.*?)(?=\d+#转炉|$)/gs)) + .map(match => match[0]); + + // 初始化数据结构 + const state = reactive({ + allSchedule: [], + result: {}, + totalRecovery: Array.from({length: totalMinutes}, () => [0]), + tankLevels: Array.from({length: totalMinutes + 1}, () => [0]) + }); + + // 解析每个转炉的数据 + furnaceBlocks.forEach(block => { + // 提取转炉编号 + const furnaceNum = parseInt(block.match(/(\d+)#/)[1]); + + // 解析时间段和回收量 + const periods = Array.from(block.matchAll(/第(\d+)-(\d+)[^\d]+?(\d+\.?\d*)km³/gs)) + .map(match => [ + parseInt(match[1]), + parseInt(match[2]), + parseFloat(match[3]) + ]); + + // 存储到结果 + state.result[furnaceNum] = periods; + + // 计算每分钟回收量 + periods.forEach(([start, end, amount]) => { + const duration = end - start; + const perMin = amount / duration; + for(let t = start; t < end; t++) { + state.totalRecovery[t][0] += perMin; + } + }); + }); + + // 生成0/1序列 + furnaceBlocks.forEach(block => { + const schedule = new Array(totalMinutes).fill(0); + Array.from(block.matchAll(/第(\d+)-(\d+)/gs)).forEach(match => { + const start = parseInt(match[1]) - 1; + const end = parseInt(match[2]) - 1; + for(let i = start; i <= end; i++) { + if(i >= 0 && i < totalMinutes) schedule[i] = 1; + } + }); + state.allSchedule.push(schedule); + }); + + // 计算柜位 + let cumulative = 0; + const consumptionRate = consume / 60; + state.tankLevels = Array.from({length: totalMinutes + 1}, (_, t) => { + if(t > 0) cumulative += state.totalRecovery[t-1][0]; + const consumed = consumptionRate * t; + return [Number((gui + cumulative - consumed).toFixed(2))]; + }); + + // 格式化输出 + return { + schedule: state.allSchedule, + result: state.result, + totalRecovery: state.totalRecovery.map(v => [Number(v[0].toFixed(2))]), + tankLevels: state.tankLevels + } +} + +/** 处理删除 message 消息 */ +const handleMessageDelete = () => { + if (conversationInProgress.value) { + message.alert('回答中,不能删除!') + return + } + // 刷新 message 列表 + getMessageList() +} + +/** 处理 message 清空 */ +const handlerMessageClear = async () => { + if (!activeConversationId.value) { + return + } + try { + // 刷新 message 列表 + activeMessageList.value = [] + } catch {} +} + +// =========== 【发送消息】相关 =========== + +/** 处理来自 keydown 的发送消息 */ +const handleSendByKeydown = async (event) => { + // 判断用户是否在输入 + if (isComposing.value) { + return + } + // 进行中不允许发送 + if (conversationInProgress.value) { + return + } + const content = prompt.value?.trim() as string + if (event.key === 'Enter') { + if (event.shiftKey) { + // 插入换行 + prompt.value += '\r\n' + event.preventDefault() // 防止默认的换行行为 + } else { + // 发送消息 + await doSendMessage(content) + event.preventDefault() // 防止默认的提交行为 + } + } +} + +/** 处理来自【发送】按钮的发送消息 */ +const handleSendByButton = () => { + doSendMessage(prompt.value?.trim() as string) +} + +/** 处理 prompt 输入变化 */ +const handlePromptInput = (event) => { + // 非输入法 输入设置为 true + if (!isComposing.value) { + // 回车 event data 是 null + if (event.data == null) { + return + } + isComposing.value = true + } + // 清理定时器 + if (inputTimeout.value) { + clearTimeout(inputTimeout.value) + } + // 重置定时器 + inputTimeout.value = setTimeout(() => { + isComposing.value = false + }, 400) +} +// TODO :是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑 +const onCompositionstart = () => { + isComposing.value = true +} +const onCompositionend = () => { + // console.log('输入结束...') + setTimeout(() => { + isComposing.value = false + }, 200) +} + +/** 真正执行【发送】消息操作 */ +const doSendMessage = async (content: string) => { + // 校验 + if (content.length < 1) { + message.error('发送失败,原因:内容为空!') + return + } + if (activeConversationId.value == null) { + message.error('还没创建对话,不能发送!') + return + } + // 发送请求时如果accessToken过期,无法中断请求,暂时增加请求前刷新token + authUtil.setToken(await refreshToken()) + // 执行发送 + await doSendMessageStream({ + conversationId: activeConversationId.value, + content: content + } as ChatMessageVO) +} + +/** 真正执行【发送】消息操作 */ +const doSendMessageStream = async (userMessage: ChatMessageVO) => { + // 创建 AbortController 实例,以便中止请求 + conversationInAbortController.value = new AbortController() + // 标记对话进行中 + conversationInProgress.value = true + // 设置为空 + receiveMessageFullText.value = '' + + try { + // 1.0 每次发送消息前先将消息记录chat message清空 + await handlerMessageClear() + // 1.1 先添加两个假数据,等 stream 返回再替换 + activeMessageList.value.push({ + id: -1, + conversationId: activeConversationId.value, + type: 'user', + content: userMessage.content, + createTime: new Date() + } as ChatMessageVO) + activeMessageList.value.push({ + id: -2, + conversationId: activeConversationId.value, + type: 'assistant', + content: '思考中...', + createTime: new Date() + } as ChatMessageVO) + // 1.2 滚动到最下面 + await nextTick() + await scrollToBottom() // 底部 + // 1.3 开始滚动 + textRoll() + + // 2. 发送 event stream + let isFirstChunk = true // 是否是第一个 chunk 消息段 + await ChatMessageApi.sendChatMessageStream( + userMessage.conversationId, + userMessage.content, + conversationInAbortController.value, + enableContext.value, + async (res) => { + const { code, data, msg } = JSON.parse(res.data) + if (code !== 0) { + message.alert(`对话异常! ${msg}`) + } + + // 如果内容为空,就不处理。 + if (data.receive.content === '') { + return + } + // 首次返回需要添加一个 message 到页面,后面的都是更新 + if (isFirstChunk) { + isFirstChunk = false + // 弹出两个假数据 + activeMessageList.value.pop() + activeMessageList.value.pop() + // 更新返回的数据 + activeMessageList.value.push(data.send) + activeMessageList.value.push(data.receive) + } + // debugger + receiveMessageFullText.value = receiveMessageFullText.value + data.receive.content + // 滚动到最下面 + await scrollToBottom() + }, + (error) => { + message.alert(`对话异常! ${error}`) + stopStream() + }, + () => { + stopStream() + } + ) + } catch {} +} + +/** 停止 stream 流式调用 */ +const stopStream = async () => { + // tip:如果 stream 进行中的 message,就需要调用 controller 结束 + if (conversationInAbortController.value) { + conversationInAbortController.value.abort() + } + // 设置为 false + conversationInProgress.value = false +} + +/** 编辑 message:设置为 prompt,可以再次编辑 */ +const handleMessageEdit = (message: ChatMessageVO) => { + prompt.value = message.content +} + +/** 刷新 message:基于指定消息,再次发起对话 */ +const handleMessageRefresh = (message: ChatMessageVO) => { + doSendMessage(message.content) +} + +// ============== 【消息滚动】相关 ============= + +/** 滚动到 message 底部 */ +const scrollToBottom = async (isIgnore?: boolean) => { + await nextTick() + if (messageRef.value) { + messageRef.value.scrollToBottom(isIgnore) + } +} + +/** 自提滚动效果 */ +const textRoll = async () => { + let index = 0 + try { + // 只能执行一次 + if (textRoleRunning.value) { + return + } + // 设置状态 + textRoleRunning.value = true + receiveMessageDisplayedText.value = '' + const task = async () => { + // 调整速度 + const diff = + (receiveMessageFullText.value.length - receiveMessageDisplayedText.value.length) / 10 + if (diff > 5) { + textSpeed.value = 10 + } else if (diff > 2) { + textSpeed.value = 30 + } else if (diff > 1.5) { + textSpeed.value = 50 + } else { + textSpeed.value = 100 + } + // 对话结束,就按 30 的速度 + if (!conversationInProgress.value) { + textSpeed.value = 10 + } + + if (index < receiveMessageFullText.value.length) { + receiveMessageDisplayedText.value += receiveMessageFullText.value[index] + index++ + + // 更新 message + const lastMessage = activeMessageList.value[activeMessageList.value.length - 1] + lastMessage.content = receiveMessageDisplayedText.value + // 滚动到住下面 + await scrollToBottom() + // 重新设置任务 + timer = setTimeout(task, textSpeed.value) + } else { + // 不是对话中可以结束 + if (!conversationInProgress.value) { + textRoleRunning.value = false + clearTimeout(timer) + } else { + // 重新设置任务 + timer = setTimeout(task, textSpeed.value) + } + } + } + let timer = setTimeout(task, textSpeed.value) + } catch {} +} + +const LDGHSLYCEhartContainer = ref(); + + // 生成未来60秒的时间标签(LDG回收量预测) +const generateLDGHSLYCTimeLabels = () => { + const labels = []; + for (let i = 0; i < 60; i++) { + labels.push(i); + } + return labels; + }; + +// 生成未来60秒和过去60秒的时间标签 +const generateLDGGRQSYCTimeLabels = () => { + const labels = []; + const now = new Date(); + // 补零函数 + const padZero = num => num.toString().padStart(2, '0') + for (let i = 0; i < 121; i++) { + const date = new Date(now.getTime() + (i-60) * 1000); + const formatted = `${date.getFullYear()}-${padZero(date.getMonth() + 1)}-${padZero(date.getDate())} ` + + `${padZero(date.getHours())}:${padZero(date.getMinutes())}:${padZero(date.getSeconds())}`; + labels.push(formatted); + } + return labels; +}; + +// 数据格式转换示例(LDG回收量预测) +const seriesHSLYCDataConverter = () => { + const recovery = modelData.value.totalRecovery + const totalRecovery = [] + recovery.forEach(item => { + totalRecovery.push(item[0]) + }) + return totalRecovery; +}; + +const seriesHSLYCDataSchedule = (type) => { + const max = LDGMaxTotalValue() + const schedule = modelData.value.schedule[type] + // 返回对象格式数据,包含原始值和基准值 + const baseline = round(max, 0) + (6 - 2 * type) + return schedule.map(item => ({ + value: item + baseline, // 显示值 = 原始值 + 基准值 + original: item // 原始值 + })); +}; + +// 计算总回收量的最大值 +const LDGMaxTotalValue = () => { + const total = modelData.value.totalRecovery + let returnValue = computed(() => { + return Math.max(...total) + }) + return returnValue.value +}; + +// 计算柜容预测趋势的最大值和最小值,用于上下界限显示 +const LDGComputedValue = (type) => { + const tank = modelData.value.tankLevels + let returnValue = 0; + if(type == 'max') { + returnValue = computed(() => { + return Math.max(...tank) + 20 + }) + } else if(type == 'min') { + returnValue = computed(() => { + return Math.min(...tank) - 60 + }) + } else if(type == 'average') { + returnValue = computed(() => { + let sum = 0 + tank.forEach((item) => { + sum += item[0] + }) + return (sum / tank.length).toFixed(0); + }) + } + return returnValue.value +}; + +// 数据格式转换示例(LDG柜容趋势预测) +const seriesGRQSYCDataConverter = () => { + const tank = modelData.value.tankLevels + const tankLevels = [] + tank.forEach(item => { + tankLevels.push(item[0]) + }) + return tankLevels; +}; + +// 图表配置 +const initLDGHSLYCChart = () => { + const LDGHSLYCChart = echarts.init(LDGHSLYCEhartContainer.value); + const option = { + tooltip: { + trigger: 'axis', + formatter: function (params) { + let tooltipContent = params[0].name + '<br/>'; // 时间标签 + params.forEach(param => { + const seriesName = param.seriesName; + let originalValue; + + // 判断是否为转炉系列 + if (seriesName.includes('转炉')) { + // 直接从数据项中获取原始值 + originalValue = param.data.original; + tooltipContent += ` + ${param.marker} + ${seriesName}: ${originalValue.toFixed(0)}<br/> + `; + } else { + // 总回收量直接显示值 + originalValue = param.value; + tooltipContent += ` + ${param.marker} + ${seriesName}: ${originalValue.toFixed(2)}<br/> + `; + } + }); + return tooltipContent; + } + }, + grid: { + left: 25, + right: 25, + bottom: 10, + top: 30, + containLabel: true + }, + legend: { + top: 10, + right: 10, + data: ['1#转炉', '2#转炉', '3#转炉', '总回收量'], + textStyle: { + color: '#8FD6FE' + }, + itemWidth: 20, // 图例标记的宽度 + itemHeight: 0, // 图例标记的高度,设为较小值使其更像线条 + }, + xAxis: { + type: 'category', + boundaryGap: false, + data: generateLDGHSLYCTimeLabels(), + axisTick: { + show: false + }, + axisLine: { + lineStyle: { + color: '#C7E7FF' + } + }, + axisLabel: { + color: '#fff' + } + }, + yAxis: { + type: 'value', + min: 0, + axisLine: { + show: true, + lineStyle: { + color: '#C7E7FF', + } + }, + axisTick: { + show: false + }, + axisLabel: { + show: false + }, + splitLine: { + show: false // Y轴网格线 + } + }, + series: [ + { + name: '1#转炉', + type: 'line', + step: 'start', + data: seriesHSLYCDataSchedule(0), + showSymbol: false, // 取消数据点 + emphasis: { + focus: 'series' + }, + lineStyle: { + color: '#FF7686' // 粉色 + } + }, + { + name: '2#转炉', + type: 'line', + step: 'start', + data: seriesHSLYCDataSchedule(1), + showSymbol: false, // 取消数据点 + emphasis: { + focus: 'series' + }, + lineStyle: { + color: '#49FFD3' // 绿色 + } + }, + { + name: '3#转炉', + type: 'line', + step: 'start', + showSymbol: false, // 取消数据点 + data: seriesHSLYCDataSchedule(2), + color: '#FAC858', + emphasis: { + focus: 'series' + }, + lineStyle: { + color: '#FFAE81' // 橙色 + }, + }, + { + name: '总回收量', + type: 'line', + step: 'start', + showSymbol: false, // 取消数据点 + data: seriesHSLYCDataConverter(), + emphasis: { + focus: 'series' + }, + lineStyle: { + color: 'white' + }, + } + ] + }; + LDGHSLYCChart.setOption(option); +}; + +const LDGGRYCEhartContainer = ref(); + +/** 带预测的转炉数据阶梯图配置 */ +const initLDGGRQSYCChart = () => { + const labels = generateLDGGRQSYCTimeLabels() + const tankLevels = seriesGRQSYCDataConverter() + const fullData = []; + for(let i = 0; i < 121; i ++ ) { + let value = 90 + if(i >= 60) { + value = tankLevels[i - 60] + } + fullData.push([labels[i], value]); + } + + const splitTime = formatToDateTime(new Date()); // 分割时间点(当前时间) + const upperLimit = LDGComputedValue('max'); + const lowerLimit = LDGComputedValue('min'); + const averageValue = LDGComputedValue('average'); + + // 分割真实数据和预测数据 + const splitIndex = fullData.findIndex(item => item[0] === splitTime); + const realData = fullData.slice(0, splitIndex + 1); + const predictData = fullData.slice(splitIndex); + + // 创建纯净的上下限数据数组 + const upperLimitData = [ + [labels[0], upperLimit], + [labels[labels.length - 1], upperLimit] + ]; + + const lowerLimitData = [ + [labels[0], lowerLimit], + [labels[labels.length - 1], lowerLimit] + ]; + + const LDGGRQSYCChart = echarts.init(LDGGRYCEhartContainer.value); + const option = { + grid: { + left: 0, + right: 0, + bottom: 10, + top: 20, + containLabel: true + }, + tooltip: { + trigger: 'axis' + }, + xAxis: { + type: 'time', + axisLabel: { + formatter: (value) => { + return echarts.time.format(value, '{mm}:{ss}', false) + }, + color: '#C7E7FF' + }, + axisLine: { + show: true, + lineStyle: { + color: '#C7E7FF' + } + }, + axisTick: { + show: false + }, + splitLine: { + show: false + } + }, + yAxis: { + type: 'value', + min: 0, + max: LDGComputedValue('max') + 30, + axisLine: { + show: true, + lineStyle: { + color: '#C7E7FF' + } + }, + axisTick: { + show: false + }, + splitLine: { + show: false + } + }, + series: [ + // 真实数据(实线) + { + type: 'line', + data: realData, + smooth: true, + symbol: 'none', + lineStyle: { color: '#95E6FF' }, + markLine: { + symbol: ['none', 'none'], + label: { + show: false + }, + data: [{ + xAxis: splitTime, + lineStyle: { + type: 'solid', + color: '#5DFF9E', + width: 1 + } + }] + } + }, + // 预测数据(虚线) + { + type: 'line', + data: predictData, + smooth: true, + symbol: 'none', + lineStyle: { + type: 'dashed', + color: '#E76666', + dashOffset: 5 + } + }, + // 上限填充 + { + type: 'line', + data: upperLimitData, + lineStyle: { width: 0 }, + areaStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: 'rgba(255,0,0,0.2)' }, // 顶部颜色 + { offset: 1, color: 'rgba(255,0,0,0.5)' } // 底部颜色(到upperLimit) + ]), + origin: 'end' // 关键:从线条向上填充 + }, + silent: true + }, + // 下限填充 + { + type: 'line', + data: lowerLimitData, + lineStyle: { width: 0 }, + areaStyle: { + color: new echarts.graphic.LinearGradient(0, 1, 0, 0, [ + { offset: 0, color: 'rgba(0,0,255,0.2)' }, + { offset: 1, color: 'rgba(0,0,255,0.5)' } + ]) + }, + silent: true + }, + // 辅助线系列 + { + type: 'line', + symbol: 'none', // 关闭辅助线系列自身的数据点 + markLine: { + symbol: ['none', 'none'], // 全局隐藏标记点 + data: [ + // 上限辅助线配置部分 + { + yAxis: upperLimit, + symbol: 'none', + label: { show: false }, + emphasis: { + // 这里添加让高亮时标记点也不显示 + itemStyle: { + borderWidth: 0, + borderColor: 'transparent', + color: 'transparent' + }, + label: { show: false } + }, + lineStyle: { color: 'rgba(255,0,0)', width: 1, type: 'solid' } + }, + // 下限辅助线配置部分 + { + yAxis: lowerLimit, + symbol: 'none', + label: { show: false }, + emphasis: { + // 这里添加让高亮时标记点也不显示 + itemStyle: { + borderWidth: 0, + borderColor: 'transparent', + color: 'transparent' + }, + label: { show: false } + }, + lineStyle: { color: 'rgba(0,0,255)', width: 1, type: 'solid' } + }, + { + yAxis: averageValue, + label: { show: false }, + lineStyle: { type: 'dashed', color: 'rgba(0,194,255,0.52)', width: 1 } + } + ] + } + } + ] + }; + LDGGRQSYCChart.setOption(option); +} + +const isFullscreen = ref(false); + +// 核心:统一检测全屏状态 +const updateFullscreenStatus = () => { + // 同时检测 API 全屏和 F11 全屏(近似) + isFullscreen.value = !!document.fullscreenElement || window.outerHeight === screen.height; +}; + +// 监听全屏 API 变化 +const handleFullscreenChange = () => { + updateFullscreenStatus(); +}; + +// 监听 F11 按键(兜底) +const handleKeyPress = (e) => { + if (e.key === 'F11') { + e.preventDefault(); // 尝试阻止默认行为(部分浏览器允许) + setTimeout(updateFullscreenStatus, 100); // 延迟确保状态更新 + } +}; + +// 监听窗口大小变化(F11 全屏会触发) +const handleResize = () => { + updateFullscreenStatus(); +}; + +// 切换全屏(API 方式) +const toggleFullscreen = async () => { + if (!document.fullscreenElement) { + await document.documentElement.requestFullscreen(); + } else { + await document.exitFullscreen(); + } +}; + +//初始化全屏信息 +const initFullscreen = async () => { + // Fullscreen API 事件 + const events = ['fullscreenchange', 'webkitfullscreenchange', 'msfullscreenchange']; + events.forEach(event => { + document.addEventListener(event, handleFullscreenChange); + }); + + // 窗口变化 + 键盘事件兜底 + window.addEventListener('resize', handleResize); + window.addEventListener('keydown', handleKeyPress); + + // 初始状态检测 + updateFullscreenStatus(); +} + +/** 初始化 **/ +onMounted(async () => { + await initFullscreen() + // 如果有 conversationId 参数,则默认选中 + if (route.query.conversationId) { + const id = route.query.conversationId as unknown as number + activeConversationId.value = id + await getConversation(id) + } + + // 获取列表数据 + activeMessageListLoading.value = true + await getMessageList() +}) + +// 清理监听 +onUnmounted(() => { + const events = ['fullscreenchange', 'webkitfullscreenchange', 'msfullscreenchange']; + events.forEach(event => { + document.removeEventListener(event, handleFullscreenChange); + }); + window.removeEventListener('resize', handleResize); + window.removeEventListener('keydown', handleKeyPress); +}); + +</script> + +<style lang="scss" scoped> +.gas-scheduling-container { + display: flex; + font-family: Microsoft YaHei, Microsoft YaHei; + .fullscreen-btn { + background-color: transparent; + border: 1px solid rgba(115, 196, 255, 0.5); + position: fixed; + margin-top: 3.5%; + margin-left: 28%; + color: rgba(115, 196, 255, 0.8); + font-size: 12px; + } + /* 背景层容器 */ + &::before { + content: ''; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; /* 置于内容层下方 */ + background: + url("@/assets/ai/zhuanlu/bg.png") center/cover no-repeat, + linear-gradient(to bottom, #0a1633dd, #0a1633dd); /* 叠加深色遮罩 */ + pointer-events: none; /* 防止遮挡交互 */ + } + .gas-scheduling-left { + width: 23%; + height: 89%; + margin: 2rem 2rem 0 1.8rem; + z-index: 1; + background-color: rgba(0, 0, 0, 0); /* 透明背景 */ + .data1-item { + height: 2.6rem; + width: 42%; + display: inline-block; + margin: 8px 10px ; + background: url("@/assets/ai/zhuanlu/data_bg1.png") center/cover no-repeat; + } + .data2-item { + height: 30px; + width: 42%; + display: inline-block; + margin: 6px 8px; + background: url("@/assets/ai/zhuanlu/data_bg2.png") center/cover no-repeat; + } + .content { + margin-left: 16px; + .value { + span:nth-child(1){ + height: 19px; + font-weight: bold; + font-size: 16px; + color: #FFAE81; + line-height: 19px; + } + span:nth-child(2) { + height: 16px; + font-weight: 400; + font-size: 12px; + color: #C7E7FF; + } + } + .name { + height: 16px; + font-weight: 400; + font-size: 12px; + color: #C7E7FF; + } + } + .content2 { + display: flex; + width: 11rem; + margin-left: 10px; + .name { + width: 95px; + height: 18px; + font-weight: 400; + font-size: 14px; + color: #C7E7FF; + } + .value { + margin-left: auto; + margin-right: 5px; + span:nth-child(1){ + height: 15px; + font-weight: bold; + font-size: 15px; + color: #FFAE81; + line-height: 15px; + } + span:nth-child(2) { + height: 15px; + font-weight: 400; + font-size: 12px; + color: #C7E7FF; + } + } + } + #mqhsssxx { + .title { + height: 2rem; + background: + url("@/assets/ai/zhuanlu/mqhsssxx_title.png") center/cover no-repeat; /* 叠加深色遮罩 */ + } + } + #tsxx { + .title { + margin-top: 5px; + height: 2rem; + background: + url("@/assets/ai/zhuanlu/tsxx_title.png") center/cover no-repeat; /* 叠加深色遮罩 */ + } + } + #zlxx { + .title { + margin-top: 5px; + height: 2rem; + background: + url("@/assets/ai/zhuanlu/zlxx_title.png") center/cover no-repeat; /* 叠加深色遮罩 */ + } + :deep(.el-table) { + font-weight: 400; + font-size: 14px; + color: #DBEEFF; + text-align: left; + background-color: transparent !important; + } + + .transparent-table { + margin-top: 14px; + } + + /* 行样式 */ + :deep(.el-table .el-table__body tr) { + border: 1px solid rgba(16, 198, 255, 0.3); + margin-bottom: 4px; + border-radius: 4px; + overflow: hidden; + background-color: transparent; + } + + /* 行内容样式 */ + :deep(.el-table .el-table__body td) { + padding: 4px 0; + color: #FFAA5D; + } + + /* 列头样式 */ + :deep(.el-table th) { + color: #8FD6FE; + background: + url("@/assets/ai/zhuanlu/table_header_bg.png") center/cover no-repeat !important; /* 叠加深色遮罩 */ + border: none; + } + + :deep(.el-table tr) { + background: transparent; + } + + /* 行头样式 */ + :deep(.el-table .el-table__body td:first-child) { + color: #8FD6FE; + font-weight: 500; + background-color: transparent; + } + + :deep(.el-table .el-table__body tr:nth-child(even) td) { + background-color: rgba(0, 194, 255, 0.1); + } + + :deep(.el-table .el-table__body tr:nth-child(odd) td) { + background-color: rgba(0, 194, 255, 0.2); + } + + /* 移除表格内部边框线 */ + :deep(.el-table td, .el-table th.is-leaf) { + border-bottom: none; + } + + :deep(.el-table .el-table__inner-wrapper:before) { + background-color: transparent !important; + } + } + #mqxhssxx { + .title { + height: 30px; + margin-top: 15px; + background: + url("@/assets/ai/zhuanlu/mqxhssxx_title.png") center/cover no-repeat; /* 叠加深色遮罩 */ + } + } + } + + .gas-scheduling-center { + margin-top: 2.6rem; + width: 55.5rem !important; + .mode-switch { + margin-top: 20px; + margin-left: 43rem; + font-weight: 400; + font-size: 14px; + color: #73C4FF; + width: 40%; + /* 必须穿透到组件内部层级 */ + :deep(.custom-radio-group) { + --el-color-primary: red !important; /* 强制修改主题色变量 */ + } + + /* 所有按钮基础样式 */ + :deep(.custom-radio-group .el-radio-button__inner) { + background: black !important; /* 未选中黑色背景 */ + border: 1px solid rgba(173, 216, 230, 0.3) !important; /* 蓝色边框 */ + font-weight: 300; + font-size: 14px; + color: #DBEEFF; + transition: all 0.3s; + } + + /* 选中状态 */ + :deep(.custom-radio-group .el-radio-button.is-active .el-radio-button__inner) { + background: #b92220 !important; + color: gold !important; + font-weight: bolder; + } + + /* 强制覆盖原生选中状态 */ + :deep(.custom-radio-group .el-radio-button__orig-radio:checked + .el-radio-button__inner) { + background: inherit !important; /* 继承上层样式 */ + } + } + + // 头部 + .detail-container { + margin-left: 5px; + background-color: rgba(0, 0, 0, 0); /* 透明背景 */ + z-index: 1; + .header { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + box-shadow: 0 0 0 0 #dcdfe6; + + .title { + font-size: 18px; + font-weight: bold; + } + + .btns { + display: flex; + width: 300px; + flex-direction: row; + justify-content: flex-end; + //justify-content: space-between; + + .btn { + padding: 10px; + } + } + } + } + + // main 容器 + .main-container { + margin: 0; + padding: 0; + position: relative; + height: 30rem; + + .message-container { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + overflow-y: hidden; + padding: 0; + margin: 0; + } + .title { + background: url("@/assets/ai/zhuanlu/think_bg.png") center/cover no-repeat; + width: auto; + height: 1.8rem; + font-weight: 400; + font-size: 14px; + color: #8FD6FE; + text-align: left; + font-style: normal; + text-transform: none; + span { + margin-left: 30px; + } + } + } + + .result-container-title { + margin-top: 15px; + background: url("@/assets/ai/zhuanlu/ddtljl_result_title.png") center/cover no-repeat; + width: auto; + height: 1.8rem; + font-weight: 400; + font-size: 14px; + color: #8FD6FE; + text-align: left; + font-style: normal; + text-transform: none; + span { + margin-left: 30px; + } + .history-button { + color: rgba(143, 214, 254); + font-weight: bold; + float: right; + margin-right: 5px; + background-color: rgba(0, 255, 255, 0.1); + border-radius: 3px; + padding: 0 5px; + border: none; + cursor: pointer + } + .history-button:hover { + color: rgba(143, 214, 254, 0.5); + } + } + // main 容器 + .result-container { + padding: 0; + position: relative; + width: 100%; + /* WebKit */ + &::-webkit-scrollbar { + width: 6px; + background: transparent; + } + &::-webkit-scrollbar-thumb { + border-radius: 4px; + background: rgba(0, 0, 0, 0.15); + transition: background 0.3s; + &:hover { background: rgba(0, 0, 0, 0.25); } + } + .result { + margin-top: 10px; + margin-left: 18px; + border-left: 1px solid #73C4FF; + .result-content { + width: 53rem; + height: 6rem; + margin-left: 10px; + font-weight: 400; + font-size: 14px; + background-color: rgba(219,238,255,0); + line-height: 21px; + text-align: left; + font-style: normal; + text-transform: none; + border: 0; + color: rgba(219,238,255,0.6); + } + .result-content:focus { + outline: none; + } + } + } + + // 输入框 + .input-container { + display: flex; + flex-direction: column; + height: auto; + margin: 0; + padding: 0; + overflow-y: auto; /* 垂直方向溢出时显示滚动条 */ + overflow-x: hidden; /* 水平方向隐藏滚动条 */ + /* Firefox */ + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.15) transparent; + + /* WebKit */ + &::-webkit-scrollbar { + width: 6px; + background: transparent; + } + &::-webkit-scrollbar-thumb { + border-radius: 4px; + background: rgba(0, 0, 0, 0.15); + transition: background 0.3s; + &:hover { background: rgba(0, 0, 0, 0.25); } + } + + .prompt-from { + display: flex; + flex-direction: column; + margin: 10px 20px 20px 20px; + padding: 9px 10px; + width: 53.9rem; + background: rgba(115,196,255,0.05); + border-radius: 4px 4px 4px 4px; + border: 1px solid #73C4FF; + } + + .prompt-input { + width: 53rem; + height: 11rem; + font-weight: 400; + font-size: 14px; + background-color: rgba(219,238,255,0); + line-height: 21px; + text-align: left; + font-style: normal; + text-transform: none; + border: 0; + color: rgba(219,238,255,0.6); + } + + .prompt-input:focus { + outline: none; + } + + .prompt-btns { + display: flex; + justify-content: space-between; + padding-bottom: 0; + padding-top: 5px; + .content { + /* 默认状态 */ + .el-button { + background: transparent !important; + border-color: rgba(115, 196, 255, 0.5); + color: #73C4FF; + border-radius: 15px !important; + } + + /* 上下文图标处理 */ + .content-icon { + color: blue; /* 图标颜色 */ + font-size: 18px; + margin-right: 10px; + background: url("@/assets/ai/zhuanlu/content.png"); + vertical-align: middle; + } + + /* 选中状态 */ + .active-button { + background: #409eff !important; + border-color: #409eff !important; + color: white !important; + .content-icon { + background: url("@/assets/ai/zhuanlu/content_select.png"); + vertical-align: middle; + } + } + + /* 按钮组间距处理 */ + .button-group .el-button { + margin-left: 0; + border-radius: 4px; + } + + /* 悬停效果 */ + .el-button:not(.active-button):hover { + border-color: rgba(115,196,255,0.5); + color: #409eff; + } + } + .message { + /* 所有状态通用透明背景 */ + :deep(.el-button) { + background: rgba(73, 254, 210, 0.8) !important; + border-color: currentColor; /* 保持与文字同色 */ + font-family: Alimama ShuHeiTi, Alimama ShuHeiTi; + font-weight: bold; + font-size: 16px; + color: #123C4E; + clip-path: polygon( + 0 0, + 100% 0, + 100% 100%, + 10px 100%, /* 右下方向留出10px */ + 0 calc(100% - 10px) /* 左上方向留出10px */ + ); + position: relative; + padding-left: 15px; /* 增加右侧留白 */ + } + + /* 悬停状态 */ + :deep(.el-button:hover) { + background: rgba(73, 254, 210, 0.6) !important; /* 轻微悬停反馈 */ + } + + /* 点击状态 */ + :deep(.el-button:active) { + background: rgba(73, 254, 210, 1) !important; + } + + /* 禁用状态 */ + :deep(.el-button.is-disabled) { + opacity: 0.6; + background: transparent !important; + } + + /* 核心样式覆盖 */ + :deep(.el-switch__core) { + background: transparent !important; + border-radius: 0 0 15px 0 !important; + border: none !important; + height: 40px !important; + } + + /* 按钮内容容器 */ + .button-content { + display: flex; + align-items: center; + padding: 0 15px; + height: 100%; + } + } + } + } + } + + .gas-scheduling-right { + width: 22%; + height: 89%; + margin-left: 4.3rem; + margin-top: 2.8rem; + z-index: 1; + background-color: rgba(0, 0, 0, 0); /* 透明背景 */ + + #ldghslyc { + .title { + height: 1.8rem; + background: + url("@/assets/ai/zhuanlu/ldghslyc_title.png") center/cover no-repeat; /* 叠加深色遮罩 */ + } + } + #ldggrqsyc { + .title { + height: 1.8rem; + background: + url("@/assets/ai/zhuanlu/ldggrqsyc_title.png") center/cover no-repeat; /* 叠加深色遮罩 */ + } + } + #mqhsjhxx { + .title { + height: 1.8rem; + background: + url("@/assets/ai/zhuanlu/mqhsjhxx_title.png") center/cover no-repeat; /* 叠加深色遮罩 */ + } + .time-content { + display: inline-block; + width: 32%; + height: 2.9rem; + margin: 10px 0 5px 5px; + font-weight: 400; + font-size: 12px; + color: #C7E7FF; + .time-content-item { + display: flex; + .name { + width: 1.6rem; + height: 3rem; + padding: 8px 3px; + background: linear-gradient( 180deg, rgba(115,196,255,0.1) 0%, rgba(255,136,69,0.1) 100%); + border-radius: 2px 2px 2px 2px; + border: 1px solid rgba(255,255,255,0.15); + opacity: 0.9; + } + .name span { + height: 1.8rem; + font-family: Alimama ShuHeiTi, Alimama ShuHeiTi; + font-weight: bold; + font-size: 14px; + color: #C7E7FF; + line-height: 15px; + text-align: left; + font-style: normal; + text-transform: none; + } + .time { + width: 105px; + height: 38px; + margin: 4px; + display: inline-block; + .in-pot{ + width: 4px; + height: 4px; + background: #49FFD3; + border-radius: 80px 80px 80px 80px; + margin-top: 7px; + margin-right: 3px; + } + .out-pot{ + width: 4px; + height: 4px; + background: #FFAE81; + border-radius: 80px 80px 80px 80px; + margin-top: 7px; + margin-right: 3px; + } + } + .time > div { + float: left; + height: 25px; + } + } + } + .data2-item { + height: 2.8rem; + width: 45%; + display: inline-block; + margin: 6px 8px; + background: url("@/assets/ai/zhuanlu/data_bg3.png") no-repeat; + } + .content { + display: flex; + width: 192px; + margin-left: 10px; + + .name { + width: 95px; + height: 18px; + font-weight: 400; + font-size: 14px; + color: #C7E7FF; + } + + .value { + margin-top: 10px; + margin-left: auto; + margin-right: 5px; + span:nth-child(1) { + height: 15px; + font-weight: bold; + font-size: 15px; + color: #FFAE81; + line-height: 15px; + } + + span:nth-child(2) { + height: 15px; + font-weight: 400; + font-size: 12px; + color: #C7E7FF; + } + } + } + } + #scmbyyxzb { + margin-top: 10px; + .title { + height: 1.8rem; + background: + url("@/assets/ai/zhuanlu/scmbyyxzb_title.png") center/cover no-repeat; /* 叠加深色遮罩 */ + } + .little-title { + font-size: 14px; + color: #8FD6FE; + margin: 10px; + } + .data3-item { + height: 5.2rem; + width: 30%; + display: inline-block; + margin: 0 6px 6px 6px; + background: url("@/assets/ai/zhuanlu/data_bg4.png") center/cover no-repeat; + .name { + font-family: Alimama ShuHeiTi, Alimama ShuHeiTi; + font-weight: bold; + font-size: 16px; + color: #FFFFFF; + margin-left: 3px; + } + .value { + color: #DBEEFF; + font-size: 14px; + margin-left: 3px; + } + .value-content { + color: #8FD6FE; + font-size: 12px; + margin-left: 3px; + } + } + .zb-content { + display: inline-block; + .item { + float: left; + } + .data4-item { + height: 2.2rem; + width: 7.8rem; + display: inline-block; + margin: 5px 0; + .content { + margin-left: 16px; + .value { + text-align: center; + span:nth-child(1){ + height: 19px; + font-weight: bold; + font-size: 16px; + color: #FFAE81; + line-height: 19px; + } + span:nth-child(2) { + height: 16px; + font-weight: 400; + font-size: 12px; + color: #C7E7FF; + } + } + .name { + font-weight: 400; + font-size: 12px; + color: #C7E7FF; + } + } + .content div { + height: 25px; + } + } + .left-label { + width: 1.2rem; + height: 3.5rem; + background: url("@/assets/ai/zhuanlu/left_label.png") center/cover no-repeat; + } + .right-label { + width: 1.2rem; + height: 3.5rem; + background: url("@/assets/ai/zhuanlu/right_label.png") center/cover no-repeat; + } + } + } + } +} + +/* 背景颜色修改 */ +:deep(.el-progress .el-progress-bar .el-progress-bar__outer) { + width: 90%; + margin: 5px 0 2px 5px; + background-color: rgba(64, 158, 255, 0.3) !important; + border-radius: 2px; +} + +/* 进度条填充颜色 */ +:deep(.el-progress-bar__inner) { + border-radius: 2px; + transition: all 0.4s cubic-bezier(0.08, 0.82, 0.17, 1); +} + +</style> diff --git a/src/views/ai/questionparamsetting/QuestionParamSettingForm.vue b/src/views/ai/questionparamsetting/QuestionParamSettingForm.vue new file mode 100644 index 0000000..869dc3f --- /dev/null +++ b/src/views/ai/questionparamsetting/QuestionParamSettingForm.vue @@ -0,0 +1,117 @@ +<template> + <div class="app-container"> + <!-- 对话框(添加 / 修改) --> + <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="45%" v-dialogDrag + append-to-body> + <el-form ref="formRef" :model="formData" :rules="formRules" v-loading="formLoading" + label-width="100px"> + <el-form-item label="问题模板id" prop="templateId"> + <el-input v-model="formData.templateId" placeholder="请输入问题模板id"/> + </el-form-item> + <el-form-item label="key" prop="settingKey"> + <el-input v-model="formData.settingKey" placeholder="请输入key"/> + </el-form-item> + <el-form-item label="参数名称" prop="settingName"> + <el-input v-model="formData.settingName" placeholder="请输入参数名称"/> + </el-form-item> + <el-form-item label="参数默认值" prop="settingValue"> + <el-input v-model="formData.settingValue" placeholder="请输入参数默认值"/> + </el-form-item> + <el-form-item label="排序" prop="sort"> + <el-input v-model="formData.sort" placeholder="请输入排序"/> + </el-form-item> + </el-form> + <div slot="footer" class="dialog-footer"> + <el-button type="primary" @click="submitForm" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </div> + </el-dialog> + </div> +</template> + +<script> +import * as QuestionParamSettingApi from '@/api/ai/questionparamsetting'; + +export default { + name: "QuestionParamSettingForm", + components: {}, + data() { + return { + // 弹出层标题 + dialogTitle: "", + // 是否显示弹出层 + dialogVisible: false, + // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 + formLoading: false, + // 表单参数 + formData: { + id: undefined, + templateId: undefined, + settingKey: undefined, + settingName: undefined, + settingValue: undefined, + sort: undefined, + }, + // 表单校验 + formRules: { + templateId: [{required: true, message: '问题模板id不能为空', trigger: 'blur'}], + }, + }; + }, + methods: { + /** 打开弹窗 */ + async open(id) { + this.dialogVisible = true; + this.reset(); + // 修改时,设置数据 + if (id) { + this.formLoading = true; + try { + const res = await QuestionParamSettingApi.getQuestionParamSetting(id); + this.formData = res.data; + this.title = "修改大模型问题设置参数"; + } finally { + this.formLoading = false; + } + } + this.title = "新增大模型问题设置参数"; + }, + /** 提交按钮 */ + async submitForm() { + // 校验主表 + await this.$refs["formRef"].validate(); + this.formLoading = true; + try { + const data = this.formData; + // 修改的提交 + if (data.id) { + await QuestionParamSettingApi.updateQuestionParamSetting(data); + this.$modal.msgSuccess("修改成功"); + this.dialogVisible = false; + this.$emit('success'); + return; + } + // 添加的提交 + await QuestionParamSettingApi.createQuestionParamSetting(data); + this.$modal.msgSuccess("新增成功"); + this.dialogVisible = false; + this.$emit('success'); + } finally { + this.formLoading = false; + } + }, + /** 表单重置 */ + reset() { + this.formData = { + id: undefined, + templateId: undefined, + settingKey: undefined, + settingName: undefined, + settingValue: undefined, + sort: undefined, + }; + this.resetForm("formRef"); + } + } +}; +</script> diff --git a/src/views/ai/questionparamsetting/index.vue b/src/views/ai/questionparamsetting/index.vue new file mode 100644 index 0000000..e71a3e2 --- /dev/null +++ b/src/views/ai/questionparamsetting/index.vue @@ -0,0 +1,148 @@ +<template> + <div class="app-container"> + <!-- 搜索工作栏 --> + <el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" + label-width="68px"> + <el-form-item label="问题模板id" prop="templateId"> + <el-input v-model="queryParams.templateId" placeholder="请输入问题模板id" clearable + @keyup.enter.native="handleQuery"/> + </el-form-item> + <el-form-item label="key" prop="settingKey"> + <el-input v-model="queryParams.settingKey" placeholder="请输入key" clearable + @keyup.enter.native="handleQuery"/> + </el-form-item> + <el-form-item label="参数名称" prop="settingName"> + <el-input v-model="queryParams.settingName" placeholder="请输入参数名称" clearable + @keyup.enter.native="handleQuery"/> + </el-form-item> + <el-form-item label="参数默认值" prop="settingValue"> + <el-input v-model="queryParams.settingValue" placeholder="请输入参数默认值" clearable + @keyup.enter.native="handleQuery"/> + </el-form-item> + <el-form-item label="排序" prop="sort"> + <el-input v-model="queryParams.sort" placeholder="请输入排序" clearable + @keyup.enter.native="handleQuery"/> + </el-form-item> + <el-form-item> + <el-button type="primary" icon="el-icon-search" @click="handleQuery">搜索</el-button> + <el-button icon="el-icon-refresh" @click="resetQuery">重置</el-button> + </el-form-item> + </el-form> + + <!-- 操作工具栏 --> + <el-row :gutter="10" class="mb8"> + <el-col :span="1.5"> + <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="openForm(undefined)" + v-hasPermi="['ai:question-param-setting:create']">新增 + </el-button> + </el-col> + <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar> + </el-row> + + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="id" align="center" prop="id"/> + <el-table-column label="问题模板id" align="center" prop="templateId"/> + <el-table-column label="key" align="center" prop="settingKey"/> + <el-table-column label="参数名称" align="center" prop="settingName"/> + <el-table-column label="参数默认值" align="center" prop="settingValue"/> + <el-table-column label="排序" align="center" prop="sort"/> + <el-table-column label="操作" align="center" class-name="small-padding fixed-width"> + <template v-slot="scope"> + <el-button size="mini" type="text" icon="el-icon-edit" @click="openForm(scope.row.id)" + v-hasPermi="['ai:question-param-setting:update']">修改 + </el-button> + <el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" + v-hasPermi="['ai:question-param-setting:delete']">删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页组件 --> + <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNo" + :limit.sync="queryParams.pageSize" + @pagination="getList"/> + <!-- 对话框(添加 / 修改) --> + <QuestionParamSettingForm ref="formRef" @success="getList"/> + </div> +</template> + +<script> +import * as QuestionParamSettingApi from '@/api/ai/questionparamsetting'; +import QuestionParamSettingForm from './QuestionParamSettingForm.vue'; + +export default { + name: "QuestionParamSetting", + components: { + QuestionParamSettingForm, + }, + data() { + return { + // 遮罩层 + loading: true, + // 显示搜索条件 + showSearch: true, + // 总条数 + total: 0, + // 大模型问题设置参数列表 + list: [], + // 是否展开,默认全部展开 + isExpandAll: true, + // 重新渲染表格状态 + refreshTable: true, + // 选中行 + currentRow: {}, + // 查询参数 + queryParams: { + pageNo: 1, + pageSize: 10, + templateId: null, + settingKey: null, + settingName: null, + settingValue: null, + sort: null, + }, + }; + }, + created() { + this.getList(); + }, + methods: { + /** 查询列表 */ + async getList() { + try { + this.loading = true; + const res = await QuestionParamSettingApi.getQuestionParamSettingPage(this.queryParams); + this.list = res.data.list; + this.total = res.data.total; + } finally { + this.loading = false; + } + }, + /** 搜索按钮操作 */ + handleQuery() { + this.queryParams.pageNo = 1; + this.getList(); + }, + /** 重置按钮操作 */ + resetQuery() { + this.resetForm("queryForm"); + this.handleQuery(); + }, + /** 添加/修改操作 */ + openForm(id) { + this.$refs["formRef"].open(id); + }, + /** 删除按钮操作 */ + async handleDelete(row) { + const id = row.id; + await this.$modal.confirm('是否确认删除大模型问题设置参数编号为"' + id + '"的数据项?') + try { + await QuestionParamSettingApi.deleteQuestionParamSetting(id); + await this.getList(); + this.$modal.msgSuccess("删除成功"); + } catch { + } + }, + } +}; +</script> diff --git a/src/views/ai/questiontemplate/QuestionTemplateForm.vue b/src/views/ai/questiontemplate/QuestionTemplateForm.vue new file mode 100644 index 0000000..acc6048 --- /dev/null +++ b/src/views/ai/questiontemplate/QuestionTemplateForm.vue @@ -0,0 +1,148 @@ +<template> + <div class="app-container"> + <!-- 对话框(添加 / 修改) --> + <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="45%" v-dialogDrag + append-to-body> + <el-form ref="formRef" :model="formData" :rules="formRules" v-loading="formLoading" + label-width="100px"> + <el-form-item label="模型id" prop="modelId"> + <el-input v-model="formData.modelId" placeholder="请输入模型id"/> + </el-form-item> + <el-form-item label="问题编号" prop="questionCode"> + <el-input v-model="formData.questionCode" placeholder="请输入问题编号"/> + </el-form-item> + <el-form-item label="问题名称" prop="questionName"> + <el-input v-model="formData.questionName" placeholder="请输入问题名称"/> + </el-form-item> + <el-form-item label="问题内容"> + <Editor v-model="formData.questionContent" :min-height="192"/> + </el-form-item> + <el-form-item label="输入个数" prop="dataLength"> + <el-input v-model="formData.dataLength" placeholder="请输入输入个数"/> + </el-form-item> + <el-form-item label="是否启用(0禁用 1启用)" prop="isEnable"> + <el-input v-model="formData.isEnable" placeholder="请输入是否启用(0禁用 1启用)"/> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="formData.remark" placeholder="请输入备注"/> + </el-form-item> + <el-form-item label="创建时间" prop="createDate"> + <el-date-picker clearable v-model="formData.createDate" type="date" + value-format="timestamp" placeholder="选择创建时间"/> + </el-form-item> + <el-form-item label="更新者" prop="updator"> + <el-input v-model="formData.updator" placeholder="请输入更新者"/> + </el-form-item> + <el-form-item label="更新时间" prop="updateDate"> + <el-date-picker clearable v-model="formData.updateDate" type="date" + value-format="timestamp" placeholder="选择更新时间"/> + </el-form-item> + </el-form> + <div slot="footer" class="dialog-footer"> + <el-button type="primary" @click="submitForm" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </div> + </el-dialog> + </div> +</template> + +<script> +import * as QuestionTemplateApi from '@/api/ai/questiontemplate'; +import Editor from '@/components/Editor'; + +export default { + name: "QuestionTemplateForm", + components: { + Editor, + }, + data() { + return { + // 弹出层标题 + dialogTitle: "", + // 是否显示弹出层 + dialogVisible: false, + // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 + formLoading: false, + // 表单参数 + formData: { + id: undefined, + modelId: undefined, + questionCode: undefined, + questionName: undefined, + questionContent: undefined, + dataLength: undefined, + isEnable: undefined, + remark: undefined, + createDate: undefined, + updator: undefined, + updateDate: undefined, + }, + // 表单校验 + formRules: { + modelId: [{required: true, message: '模型id不能为空', trigger: 'blur'}], + questionCode: [{required: true, message: '问题编号不能为空', trigger: 'blur'}], + }, + }; + }, + methods: { + /** 打开弹窗 */ + async open(id) { + this.dialogVisible = true; + this.reset(); + // 修改时,设置数据 + if (id) { + this.formLoading = true; + try { + const res = await QuestionTemplateApi.getQuestionTemplate(id); + this.formData = res.data; + this.title = "修改大模型问题模板"; + } finally { + this.formLoading = false; + } + } + this.title = "新增大模型问题模板"; + }, + /** 提交按钮 */ + async submitForm() { + // 校验主表 + await this.$refs["formRef"].validate(); + this.formLoading = true; + try { + const data = this.formData; + // 修改的提交 + if (data.id) { + await QuestionTemplateApi.updateQuestionTemplate(data); + this.$modal.msgSuccess("修改成功"); + this.dialogVisible = false; + this.$emit('success'); + return; + } + // 添加的提交 + await QuestionTemplateApi.createQuestionTemplate(data); + this.$modal.msgSuccess("新增成功"); + this.dialogVisible = false; + this.$emit('success'); + } finally { + this.formLoading = false; + } + }, + /** 表单重置 */ + reset() { + this.formData = { + id: undefined, + modelId: undefined, + questionCode: undefined, + questionName: undefined, + questionContent: undefined, + dataLength: undefined, + isEnable: undefined, + remark: undefined, + createDate: undefined, + updator: undefined, + updateDate: undefined, + }; + this.resetForm("formRef"); + } + } +}; +</script> diff --git a/src/views/ai/questiontemplate/index.vue b/src/views/ai/questiontemplate/index.vue new file mode 100644 index 0000000..d8078ea --- /dev/null +++ b/src/views/ai/questiontemplate/index.vue @@ -0,0 +1,186 @@ +<template> + <div class="app-container"> + <!-- 搜索工作栏 --> + <el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" + label-width="68px"> + <el-form-item label="模型id" prop="modelId"> + <el-input v-model="queryParams.modelId" placeholder="请输入模型id" clearable + @keyup.enter.native="handleQuery"/> + </el-form-item> + <el-form-item label="问题编号" prop="questionCode"> + <el-input v-model="queryParams.questionCode" placeholder="请输入问题编号" clearable + @keyup.enter.native="handleQuery"/> + </el-form-item> + <el-form-item label="问题名称" prop="questionName"> + <el-input v-model="queryParams.questionName" placeholder="请输入问题名称" clearable + @keyup.enter.native="handleQuery"/> + </el-form-item> + <el-form-item label="输入个数" prop="dataLength"> + <el-input v-model="queryParams.dataLength" placeholder="请输入输入个数" clearable + @keyup.enter.native="handleQuery"/> + </el-form-item> + <el-form-item label="是否启用(0禁用 1启用)" prop="isEnable"> + <el-input v-model="queryParams.isEnable" placeholder="请输入是否启用(0禁用 1启用)" clearable + @keyup.enter.native="handleQuery"/> + </el-form-item> + <el-form-item label="备注" prop="remark"> + <el-input v-model="queryParams.remark" placeholder="请输入备注" clearable + @keyup.enter.native="handleQuery"/> + </el-form-item> + <el-form-item label="创建时间" prop="createDate"> + <el-date-picker v-model="queryParams.createDate" style="width: 240px" + value-format="yyyy-MM-dd HH:mm:ss" type="daterange" + range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" + :default-time="['00:00:00', '23:59:59']"/> + </el-form-item> + <el-form-item label="更新者" prop="updator"> + <el-input v-model="queryParams.updator" placeholder="请输入更新者" clearable + @keyup.enter.native="handleQuery"/> + </el-form-item> + <el-form-item label="更新时间" prop="updateDate"> + <el-date-picker v-model="queryParams.updateDate" style="width: 240px" + value-format="yyyy-MM-dd HH:mm:ss" type="daterange" + range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期" + :default-time="['00:00:00', '23:59:59']"/> + </el-form-item> + <el-form-item> + <el-button type="primary" icon="el-icon-search" @click="handleQuery">搜索</el-button> + <el-button icon="el-icon-refresh" @click="resetQuery">重置</el-button> + </el-form-item> + </el-form> + + <!-- 操作工具栏 --> + <el-row :gutter="10" class="mb8"> + <el-col :span="1.5"> + <el-button type="primary" plain icon="el-icon-plus" size="mini" @click="openForm(undefined)" + v-hasPermi="['ai:question-template:create']">新增 + </el-button> + </el-col> + <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar> + </el-row> + + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="id" align="center" prop="id"/> + <el-table-column label="模型id" align="center" prop="modelId"/> + <el-table-column label="问题编号" align="center" prop="questionCode"/> + <el-table-column label="问题名称" align="center" prop="questionName"/> + <el-table-column label="问题内容" align="center" prop="questionContent"/> + <el-table-column label="输入个数" align="center" prop="dataLength"/> + <el-table-column label="是否启用(0禁用 1启用)" align="center" prop="isEnable"/> + <el-table-column label="备注" align="center" prop="remark"/> + <el-table-column label="创建时间" align="center" prop="createDate" width="180"> + <template v-slot="scope"> + <span>{{ parseTime(scope.row.createDate) }}</span> + </template> + </el-table-column> + <el-table-column label="更新者" align="center" prop="updator"/> + <el-table-column label="更新时间" align="center" prop="updateDate" width="180"> + <template v-slot="scope"> + <span>{{ parseTime(scope.row.updateDate) }}</span> + </template> + </el-table-column> + <el-table-column label="操作" align="center" class-name="small-padding fixed-width"> + <template v-slot="scope"> + <el-button size="mini" type="text" icon="el-icon-edit" @click="openForm(scope.row.id)" + v-hasPermi="['ai:question-template:update']">修改 + </el-button> + <el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" + v-hasPermi="['ai:question-template:delete']">删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页组件 --> + <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNo" + :limit.sync="queryParams.pageSize" + @pagination="getList"/> + <!-- 对话框(添加 / 修改) --> + <QuestionTemplateForm ref="formRef" @success="getList"/> + </div> +</template> + +<script> +import * as QuestionTemplateApi from '@/api/ai/questiontemplate'; +import QuestionTemplateForm from './QuestionTemplateForm.vue'; + +export default { + name: "QuestionTemplate", + components: { + QuestionTemplateForm, + }, + data() { + return { + // 遮罩层 + loading: true, + // 显示搜索条件 + showSearch: true, + // 总条数 + total: 0, + // 大模型问题模板列表 + list: [], + // 是否展开,默认全部展开 + isExpandAll: true, + // 重新渲染表格状态 + refreshTable: true, + // 选中行 + currentRow: {}, + // 查询参数 + queryParams: { + pageNo: 1, + pageSize: 10, + modelId: null, + questionCode: null, + questionName: null, + questionContent: null, + dataLength: null, + isEnable: null, + remark: null, + createDate: [], + updator: null, + updateDate: [], + }, + }; + }, + created() { + this.getList(); + }, + methods: { + /** 查询列表 */ + async getList() { + try { + this.loading = true; + const res = await QuestionTemplateApi.getQuestionTemplatePage(this.queryParams); + this.list = res.data.list; + this.total = res.data.total; + } finally { + this.loading = false; + } + }, + /** 搜索按钮操作 */ + handleQuery() { + this.queryParams.pageNo = 1; + this.getList(); + }, + /** 重置按钮操作 */ + resetQuery() { + this.resetForm("queryForm"); + this.handleQuery(); + }, + /** 添加/修改操作 */ + openForm(id) { + this.$refs["formRef"].open(id); + }, + /** 删除按钮操作 */ + async handleDelete(row) { + const id = row.id; + await this.$modal.confirm('是否确认删除大模型问题模板编号为"' + id + '"的数据项?') + try { + await QuestionTemplateApi.deleteQuestionTemplate(id); + await this.getList(); + this.$modal.msgSuccess("删除成功"); + } catch { + } + }, + } +}; +</script> -- Gitblit v1.9.3