| | |
| | | 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 { |
| | |
| | | model: number // 模型标志 |
| | | modelId: number // 模型编号 |
| | | content: string // 聊天内容 |
| | | thinking: string // 聊天思考 |
| | | thinkingFlag: boolean // 聊天思考 |
| | | conclusion: string // 聊天结论 |
| | | tokens: number // 消耗 Token 数量 |
| | | segmentIds?: number[] // 段落编号 |
| | | segments?: { |
| | |
| | | }) |
| | | }, |
| | | // 发送 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) => { |
| | |
| | | }) |
| | | }, |
| | | |
| | | // 删除消息【工业大模型专用】 |
| | | 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) => { |
对比新文件 |
| | |
| | | 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'
|
| | | })
|
| | | }
|
对比新文件 |
| | |
| | | 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'
|
| | | })
|
| | | }
|
对比新文件 |
| | |
| | | <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> |
对比新文件 |
| | |
| | | <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> |
| | |
| | | |
| | | /** 保留换行符 */ |
| | | const formatContent = (text) => { |
| | | return text.replace(/\n/g, '<br>') |
| | | if (text) { |
| | | return text.replace(/\n/g, '<br>') |
| | | } |
| | | return text |
| | | } |
| | | |
| | | /** 初始化 **/ |
| | |
| | | import {Layout} from '@/utils/routerHelper' |
| | | import {meta} from "eslint-plugin-prettier"; |
| | | |
| | | const { t } = useI18n() |
| | | |
| | | /** |
| | | * redirect: noredirect 当设置 noredirect 的时候该路由在面包屑导航中不可被点击 |
| | | * name:'router-name' 设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题 |
| | |
| | | { |
| | | 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', |
对比新文件 |
| | |
| | | // 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` // 限制最大缩放比例 |
| | | } |
对比新文件 |
| | |
| | | <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> |
对比新文件 |
| | |
| | | <!-- 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> |
对比新文件 |
| | |
| | | <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> |
| | |
| | | |
| | | // 1.1 获取 对话数据 |
| | | conversationList.value = await ChatConversationApi.getChatConversationEnergyList(modelName.value) |
| | | console.log(conversationList.value) |
| | | if(conversationList.value.length == 0) { |
| | | await createConversation() |
| | | } |
对比新文件 |
| | |
| | | <!-- 无聊天对话时,在 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> |
对比新文件 |
| | |
| | | <!-- 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> |
对比新文件 |
| | |
| | | <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> |
对比新文件 |
| | |
| | | <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> |
| | |
| | | <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> |
| | |
| | | </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 到粘贴板 |
| | |
| | | // 判断“消息列表”滚动的位置(用于判断是否需要滚动到消息最下方) |
| | | 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({ |
| | |
| | | /** 回到底部 */ |
| | | 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 调用 |
| | | |
| | | // ============ 处理消息操作 ============== |
| | | |
| | |
| | | /** 删除 */ |
| | | const onDelete = async (id) => { |
| | | // 删除 message |
| | | await ChatMessageApi.deleteEnergyChatMessage(id) |
| | | await ChatMessageApi.deleteChatMessage(id) |
| | | message.success('删除成功!') |
| | | // 回调 |
| | | emits('onDeleteSuccess') |
| | |
| | | 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; |
| | |
| | | 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; |
| | | } |
| | | } |
| | | |
| | | // 复制、删除按钮 |
| | |
| | | <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 { |
| | |
| | | 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; |
| | | } |
| | | } |
| | | } |
对比新文件 |
| | |
| | | <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> |
对比新文件 |
| | |
| | | <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> |
对比新文件 |
| | |
| | | <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> |
对比新文件 |
| | |
| | | <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> |
对比新文件 |
| | |
| | | <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> |
对比新文件 |
| | |
| | | <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> |