diff --git a/package.json b/package.json index b450bf69..c8dab2b8 100644 --- a/package.json +++ b/package.json @@ -6,17 +6,17 @@ "private": false, "scripts": { "i": "pnpm install", - "dev": "vite", + "dev": "vite --mode env.local", "dev-server": "vite --mode dev", "ts:check": "vue-tsc --noEmit", "build:local": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build", - "build:dev": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode local-dev", + "build:dev": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode dev", "build:test": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode test", "build:stage": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode stage", "build:prod": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode prod", "serve:dev": "vite preview --mode dev", "serve:prod": "vite preview --mode prod", - "preview": "pnpm build:local-dev && vite preview", + "preview": "pnpm build:local && vite preview", "clean": "npx rimraf node_modules", "clean:cache": "npx rimraf node_modules/.cache", "lint:eslint": "eslint --fix --ext .js,.ts,.vue ./src", @@ -29,6 +29,7 @@ "@form-create/designer": "^3.1.3", "@form-create/element-ui": "^3.1.24", "@iconify/iconify": "^3.1.1", + "@microsoft/fetch-event-source": "^2.0.1", "@videojs-player/vue": "^1.0.0", "@vueuse/core": "^10.9.0", "@wangeditor/editor": "^5.1.23", @@ -51,6 +52,7 @@ "highlight.js": "^11.9.0", "jsencrypt": "^3.3.2", "lodash-es": "^4.17.21", + "marked": "^12.0.2", "min-dash": "^4.1.1", "mitt": "^3.0.1", "nprogress": "^0.2.0", diff --git a/src/api/ai/chat/conversation/index.ts b/src/api/ai/chat/conversation/index.ts new file mode 100644 index 00000000..683646ea --- /dev/null +++ b/src/api/ai/chat/conversation/index.ts @@ -0,0 +1,54 @@ +import request from '@/config/axios' + +// AI 聊天会话 VO +export interface ChatConversationVO { + id: number // ID 编号 + userId: number // 用户编号 + title: string // 会话标题 + pinned: boolean // 是否置顶 + roleId: number // 角色编号 + modelId: number // 模型编号 + model: string // 模型标志 + temperature: number // 温度参数 + maxTokens: number // 单条回复的最大 Token 数量 + maxContexts: number // 上下文的最大 Message 数量 + updateTime: number // 更新时间 + // 额外字段 + modelName?: string // 模型名字 + roleAvatar?: string // 角色头像 + modelMaxTokens?: string // 模型的单条回复的最大 Token 数量 + modelMaxContexts?: string // 模型的上下文的最大 Message 数量 +} + +// AI 聊天会话 API +export const ChatConversationApi = { + // 获得【我的】聊天会话 + getChatConversationMy: async (id: number) => { + return await request.get({ url: `/ai/chat/conversation/get-my?id=${id}` }) + }, + + // 新增【我的】聊天会话 + createChatConversationMy: async (data?: ChatConversationVO) => { + return await request.post({ url: `/ai/chat/conversation/create-my`, data }) + }, + + // 更新【我的】聊天会话 + updateChatConversationMy: async (data: ChatConversationVO) => { + return await request.put({ url: `/ai/chat/conversation/update-my`, data }) + }, + + // 删除【我的】聊天会话 + deleteChatConversationMy: async (id: number) => { + return await request.delete({ url: `/ai/chat/conversation/delete-my?id=${id}` }) + }, + + // 删除【我的】所有对话,置顶除外 + deleteMyAllExceptPinned: async () => { + return await request.delete({ url: `/ai/chat/conversation/delete-my-all-except-pinned` }) + }, + + // 获得【我的】聊天会话列表 + getChatConversationMyList: async () => { + return await request.get({ url: `/ai/chat/conversation/my-list` }) + } +} diff --git a/src/api/ai/chat/message/index.ts b/src/api/ai/chat/message/index.ts new file mode 100644 index 00000000..05b4d804 --- /dev/null +++ b/src/api/ai/chat/message/index.ts @@ -0,0 +1,67 @@ +import request from '@/config/axios' +import { fetchEventSource } from '@microsoft/fetch-event-source' +import { getAccessToken } from '@/utils/auth' +import { config } from '@/config/axios/config' + +// 聊天VO +export interface ChatMessageVO { + id: number // 编号 + conversationId: number // 会话编号 + type: string // 消息类型 + userId: string // 用户编号 + roleId: string // 角色编号 + model: number // 模型标志 + modelId: number // 模型编号 + content: string // 聊天内容 + tokens: number // 消耗 Token 数量 + createTime: Date // 创建时间 +} + +export interface ChatMessageSendVO { + conversationId: string // 会话编号 + content: number // 聊天内容 +} + +// AI chat 聊天 +export const ChatMessageApi = { + // 消息列表 + messageList: async (conversationId: number | null) => { + return await request.get({ + url: `/ai/chat/message/list-by-conversation-id?conversationId=${conversationId}` + }) + }, + + // 发送 send stream 消息 + // TODO axios 可以么? https://apifox.com/apiskills/how-to-create-axios-stream/ + sendStream: async ( + conversationId: number, + content: string, + ctrl, + onMessage, + onError, + onClose + ) => { + const token = getAccessToken() + return fetchEventSource(`${config.base_url}/ai/chat/message/send-stream`, { + method: 'post', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + openWhenHidden: true, + body: JSON.stringify({ + conversationId, + content + }), + onmessage: onMessage, + onerror: onError, + onclose: onClose, + signal: ctrl.signal + }) + }, + + // 发送 send 消息 + delete: async (id: string) => { + return await request.delete({ url: `/ai/chat/message/delete?id=${id}` }) + } +} diff --git a/src/api/ai/model/apiKey/index.ts b/src/api/ai/model/apiKey/index.ts index 47e415e4..ed94836e 100644 --- a/src/api/ai/model/apiKey/index.ts +++ b/src/api/ai/model/apiKey/index.ts @@ -12,27 +12,32 @@ export interface ApiKeyVO { // AI API 密钥 API export const ApiKeyApi = { - // 查询AI API 密钥分页 + // 查询 API 密钥分页 getApiKeyPage: async (params: any) => { return await request.get({ url: `/ai/api-key/page`, params }) }, - // 查询AI API 密钥详情 + // 获得 API 密钥列表 + getApiKeySimpleList: async () => { + return await request.get({ url: `/ai/api-key/simple-list` }) + }, + + // 查询 API 密钥详情 getApiKey: async (id: number) => { return await request.get({ url: `/ai/api-key/get?id=` + id }) }, - // 新增AI API 密钥 + // 新增 API 密钥 createApiKey: async (data: ApiKeyVO) => { return await request.post({ url: `/ai/api-key/create`, data }) }, - // 修改AI API 密钥 + // 修改 API 密钥 updateApiKey: async (data: ApiKeyVO) => { return await request.put({ url: `/ai/api-key/update`, data }) }, - // 删除AI API 密钥 + // 删除 API 密钥 deleteApiKey: async (id: number) => { return await request.delete({ url: `/ai/api-key/delete?id=` + id }) } diff --git a/src/api/ai/model/chatModel/index.ts b/src/api/ai/model/chatModel/index.ts new file mode 100644 index 00000000..c2ef4c8d --- /dev/null +++ b/src/api/ai/model/chatModel/index.ts @@ -0,0 +1,53 @@ +import request from '@/config/axios' + +// AI 聊天模型 VO +export interface ChatModelVO { + id: number // 编号 + keyId: number // API 秘钥编号 + name: string // 模型名字 + model: string // 模型标识 + platform: string // 模型平台 + sort: number // 排序 + status: number // 状态 + temperature: number // 温度参数 + maxTokens: number // 单条回复的最大 Token 数量 + maxContexts: number // 上下文的最大 Message 数量 +} + +// AI 聊天模型 API +export const ChatModelApi = { + // 查询聊天模型分页 + getChatModelPage: async (params: any) => { + return await request.get({ url: `/ai/chat-model/page`, params }) + }, + + // 获得聊天模型列表 + getChatModelSimpleList: async (status?: number) => { + return await request.get({ + url: `/ai/chat-model/simple-list`, + params: { + status + } + }) + }, + + // 查询聊天模型详情 + getChatModel: async (id: number) => { + return await request.get({ url: `/ai/chat-model/get?id=` + id }) + }, + + // 新增聊天模型 + createChatModel: async (data: ChatModelVO) => { + return await request.post({ url: `/ai/chat-model/create`, data }) + }, + + // 修改聊天模型 + updateChatModel: async (data: ChatModelVO) => { + return await request.put({ url: `/ai/chat-model/update`, data }) + }, + + // 删除聊天模型 + deleteChatModel: async (id: number) => { + return await request.delete({ url: `/ai/chat-model/delete?id=` + id }) + } +} diff --git a/src/api/ai/model/chatRole/index.ts b/src/api/ai/model/chatRole/index.ts new file mode 100644 index 00000000..a9fce13c --- /dev/null +++ b/src/api/ai/model/chatRole/index.ts @@ -0,0 +1,80 @@ +import request from '@/config/axios' + +// AI 聊天角色 VO +export interface ChatRoleVO { + id: number // 角色编号 + modelId: number // 模型编号 + name: string // 角色名称 + avatar: string // 角色头像 + category: string // 角色类别 + sort: number // 角色排序 + description: string // 角色描述 + systemMessage: string // 角色设定 + welcomeMessage: string // 角色设定 + publicStatus: boolean // 是否公开 + status: number // 状态 +} + +// AI 聊天角色 分页请求 vo +export interface ChatRolePageReqVO { + name?: string // 角色名称 + category?: string // 角色类别 + publicStatus: boolean // 是否公开 + pageNo: number // 是否公开 + pageSize: number // 是否公开 +} + +// AI 聊天角色 API +export const ChatRoleApi = { + // 查询聊天角色分页 + getChatRolePage: async (params: any) => { + return await request.get({ url: `/ai/chat-role/page`, params }) + }, + + // 查询聊天角色详情 + getChatRole: async (id: number) => { + return await request.get({ url: `/ai/chat-role/get?id=` + id }) + }, + + // 新增聊天角色 + createChatRole: async (data: ChatRoleVO) => { + return await request.post({ url: `/ai/chat-role/create`, data }) + }, + + // 修改聊天角色 + updateChatRole: async (data: ChatRoleVO) => { + return await request.put({ url: `/ai/chat-role/update`, data }) + }, + + // 删除聊天角色 + deleteChatRole: async (id: number) => { + return await request.delete({ url: `/ai/chat-role/delete?id=` + id }) + }, + + // ======= chat 聊天 + + // 获取 my role + getMyPage: async (params: ChatRolePageReqVO) => { + return await request.get({ url: `/ai/chat-role/my-page`, params}) + }, + + // 获取角色分类 + getCategoryList: async () => { + return await request.get({ url: `/ai/chat-role/category-list`}) + }, + + // 创建角色 + createMy: async (data: ChatRoleVO) => { + return await request.post({ url: `/ai/chat-role/create-my`, data}) + }, + + // 更新角色 + updateMy: async (data: ChatRoleVO) => { + return await request.put({ url: `/ai/chat-role/update-my`, data}) + }, + + // 删除角色 my + deleteMy: async (id: number) => { + return await request.delete({ url: `/ai/chat-role/delete-my?id=` + id }) + }, +} diff --git a/src/assets/ai/copy-style2.svg b/src/assets/ai/copy-style2.svg new file mode 100644 index 00000000..2d56a87f --- /dev/null +++ b/src/assets/ai/copy-style2.svg @@ -0,0 +1 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1715606039621" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4256" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M878.250667 981.333333H375.338667a104.661333 104.661333 0 0 1-104.661334-104.661333V375.338667a104.661333 104.661333 0 0 1 104.661334-104.661334h502.912a104.661333 104.661333 0 0 1 104.661333 104.661334v502.912C981.333333 934.485333 934.485333 981.333333 878.250667 981.333333zM375.338667 364.373333a10.666667 10.666667 0 0 0-10.922667 10.965334v502.912c0 6.229333 4.693333 10.922667 10.922667 10.922666h502.912a10.666667 10.666667 0 0 0 10.922666-10.922666V375.338667a10.666667 10.666667 0 0 0-10.922666-10.922667H375.338667z" fill="#ffffff" p-id="4257"></path><path d="M192.597333 753.322667H147.328A104.661333 104.661333 0 0 1 42.666667 648.661333V147.328A104.661333 104.661333 0 0 1 147.328 42.666667H650.24a104.661333 104.661333 0 0 1 104.618667 104.661333v49.962667c0 26.538667-20.309333 46.848-46.848 46.848a46.037333 46.037333 0 0 1-46.848-46.848V147.328a10.666667 10.666667 0 0 0-10.922667-10.965333H147.328a10.666667 10.666667 0 0 0-10.965333 10.965333V650.24c0 6.229333 4.693333 10.922667 10.965333 10.922667h45.269333c26.538667 0 46.848 20.309333 46.848 46.848 0 26.538667-21.845333 45.312-46.848 45.312z" fill="#ffffff" p-id="4258"></path></svg> \ No newline at end of file diff --git a/src/assets/ai/copy.svg b/src/assets/ai/copy.svg new file mode 100644 index 00000000..f51f8d81 --- /dev/null +++ b/src/assets/ai/copy.svg @@ -0,0 +1 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1715352878351" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1499" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M624.5 786.3c92.9 0 168.2-75.3 168.2-168.2V309c0-92.4-75.3-168.2-168.2-168.2H303.6c-92.4 0-168.2 75.3-168.2 168.2v309.1c0 92.4 75.3 168.2 168.2 168.2h320.9zM178.2 618.1V309c0-69.4 56.1-125.5 125.5-125.5h320.9c69.4 0 125.5 56.1 125.5 125.5v309.1c0 69.4-56.1 125.5-125.5 125.5h-321c-69.4 0-125.4-56.1-125.4-125.5z" p-id="1500" fill="#8a8a8a"></path><path d="M849.8 295.1v361.5c0 102.7-83.6 186.3-186.3 186.3H279.1v42.7h384.4c126.3 0 229.1-102.8 229.1-229.1V295.1h-42.8zM307.9 361.8h312.3c11.8 0 21.4-9.6 21.4-21.4 0-11.8-9.6-21.4-21.4-21.4H307.9c-11.8 0-21.4 9.6-21.4 21.4 0 11.9 9.6 21.4 21.4 21.4zM307.9 484.6h312.3c11.8 0 21.4-9.6 21.4-21.4 0-11.8-9.6-21.4-21.4-21.4H307.9c-11.8 0-21.4 9.6-21.4 21.4 0 11.9 9.6 21.4 21.4 21.4z" p-id="1501" fill="#8a8a8a"></path><path d="M620.2 607.4c11.8 0 21.4-9.6 21.4-21.4 0-11.8-9.6-21.4-21.4-21.4H307.9c-11.8 0-21.4 9.6-21.4 21.4 0 11.8 9.6 21.4 21.4 21.4h312.3z" p-id="1502" fill="#8a8a8a"></path></svg> \ No newline at end of file diff --git a/src/assets/ai/delete.svg b/src/assets/ai/delete.svg new file mode 100644 index 00000000..d2ee18ed --- /dev/null +++ b/src/assets/ai/delete.svg @@ -0,0 +1 @@ +<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1715354120346" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3256" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M907.1 263.7H118.9c-9.1 0-16.4-7.3-16.4-16.4s7.3-16.4 16.4-16.4H907c9.1 0 16.4 7.3 16.4 16.4s-7.3 16.4-16.3 16.4z" fill="#8a8a8a" p-id="3257"></path><path d="M772.5 928.3H257.4c-27.7 0-50.2-22.5-50.2-50.2V247.2c0-9.1 7.3-16.4 16.4-16.4H801c12.1 0 21.9 9.8 21.9 21.9v625.2c0 27.8-22.6 50.4-50.4 50.4zM240 263.7v614.4c0 9.6 7.8 17.4 17.4 17.4h515.2c9.7 0 17.5-7.9 17.5-17.5V263.7H240zM657.4 131.1H368.6c-9.1 0-16.4-7.3-16.4-16.4s7.3-16.4 16.4-16.4h288.7c9.1 0 16.4 7.3 16.4 16.4s-7.3 16.4-16.3 16.4z" fill="#8a8a8a" p-id="3258"></path><path d="M416 754.5c-9.1 0-16.4-7.3-16.4-16.4V517.8c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4V738c0.1 9.1-7.3 16.5-16.4 16.5z" fill="#8a8a8a" p-id="3259"></path><path d="M416 465.2c-9.1 0-16.4-7.3-16.4-16.4v-59.4c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4v59.4c0.1 9.1-7.3 16.4-16.4 16.4zM604.9 754.5c-9.1 0-16.4-7.3-16.4-16.4v-67.2c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4V738c0 9.1-7.3 16.5-16.4 16.5z" fill="#8a8a8a" opacity=".4" p-id="3260"></path><path d="M604.9 619.1c-9.1 0-16.4-7.3-16.4-16.4V389.4c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4v213.3c0 9.1-7.3 16.4-16.4 16.4z" fill="#8a8a8a" p-id="3261"></path></svg> \ No newline at end of file diff --git a/src/components/MarkdownView/index.vue b/src/components/MarkdownView/index.vue new file mode 100644 index 00000000..6ecb2ea5 --- /dev/null +++ b/src/components/MarkdownView/index.vue @@ -0,0 +1,213 @@ + +<template> + <div v-html="contentHtml"></div> +</template> + +<script setup lang="ts"> +import {marked} from 'marked' +import 'highlight.js/styles/vs2015.min.css' +import hljs from 'highlight.js' +import {ref} from "vue"; + +// 代码高亮:https://highlightjs.org/ +// 转换 markdown:marked + +// marked 渲染器 +const renderer = { + code(code, language, c) { + const highlightHtml = hljs.highlight(code, {language: language, ignoreIllegals: true}).value + const copyHtml = `<div id="copy" data-copy='${code}' style="position: absolute; right: 10px; top: 5px; color: #fff;cursor: pointer;">复制</div>` + return `<pre>${copyHtml}<code class="hljs">${highlightHtml}</code></pre>` + } +} + +// 配置 marked +marked.use({ + renderer: renderer +}) + +// 渲染的html内容 +const contentHtml = ref<any>() + +// 定义组件属性 +const props = defineProps({ + content: { + type: String, + required: true + } +}) + +// 将 props 变为引用类型 +const { content } = toRefs(props) + +// 监听 content 变化 +watch(content, async (newValue, oldValue) => { + await renderMarkdown(newValue); +}) + +// 渲染 markdown +const renderMarkdown = async (content: string) => { + contentHtml.value = await marked(content) +} + +// 组件挂在时 +onMounted(async () => { + // 解析转换 markdown + await renderMarkdown(props.content as string); +}) +</script> + + +<style scoped lang="scss"> +.markdown-view { + font-family: PingFang SC; + font-size: 0.95rem; + font-weight: 400; + line-height: 1.6rem; + letter-spacing: 0em; + text-align: left; + color: #3b3e55; + max-width: 100%; + + pre { + position: relative; + } + + pre code.hljs { + width: auto; + } + + code.hljs { + border-radius: 6px; + padding-top: 20px; + width: auto; + @media screen and (min-width: 1536px) { + width: 960px; + } + + @media screen and (max-width: 1536px) and (min-width: 1024px) { + width: calc(100vw - 400px - 64px - 32px * 2); + } + + @media screen and (max-width: 1024px) and (min-width: 768px) { + width: calc(100vw - 32px * 2); + } + + @media screen and (max-width: 768px) { + width: calc(100vw - 16px * 2); + } + } + + p, + code.hljs { + margin-bottom: 16px; + } + + p { + //margin-bottom: 1rem !important; + margin: 0; + margin-bottom: 3px; + } + + /* 标题通用格式 */ + h1, + h2, + h3, + h4, + h5, + h6 { + color: var(--color-G900); + margin: 24px 0 8px; + font-weight: 600; + } + + h1 { + font-size: 22px; + line-height: 32px; + } + + h2 { + font-size: 20px; + line-height: 30px; + } + + h3 { + font-size: 18px; + line-height: 28px; + } + + h4 { + font-size: 16px; + line-height: 26px; + } + + h5 { + font-size: 16px; + line-height: 24px; + } + + h6 { + font-size: 16px; + line-height: 24px; + } + + /* 列表(有序,无序) */ + ul, + ol { + margin: 0 0 8px 0; + padding: 0; + font-size: 16px; + line-height: 24px; + color: #3b3e55; // var(--color-CG600); + } + + li { + margin: 4px 0 0 20px; + margin-bottom: 1rem; + } + + ol > li { + list-style-type: decimal; + margin-bottom: 1rem; + // 表达式,修复有序列表序号展示不全的问题 + // &:nth-child(n + 10) { + // margin-left: 30px; + // } + + // &:nth-child(n + 100) { + // margin-left: 30px; + // } + } + + ul > li { + list-style-type: disc; + font-size: 16px; + line-height: 24px; + margin-right: 11px; + margin-bottom: 1rem; + color: #3b3e55; // var(--color-G900); + } + + ol ul, + ol ul > li, + ul ul, + ul ul li { + // list-style: circle; + font-size: 16px; + list-style: none; + margin-left: 6px; + margin-bottom: 1rem; + } + + ul ul ul, + ul ul ul li, + ol ol, + ol ol > li, + ol ul ul, + ol ul ul > li, + ul ol, + ul ol > li { + list-style: square; + } +} +</style> diff --git a/src/components/bpmnProcessDesigner/package/penal/flow-condition/FlowCondition.vue b/src/components/bpmnProcessDesigner/package/penal/flow-condition/FlowCondition.vue index 345670ae..1715d73b 100644 --- a/src/components/bpmnProcessDesigner/package/penal/flow-condition/FlowCondition.vue +++ b/src/components/bpmnProcessDesigner/package/penal/flow-condition/FlowCondition.vue @@ -79,13 +79,13 @@ const resetFlowCondition = () => { bpmnElement.value = bpmnInstances().bpmnElement bpmnElementSource.value = bpmnElement.value.source bpmnElementSourceRef.value = bpmnElement.value.businessObject.sourceRef + // 初始化默认type为default + flowConditionForm.value = { type: 'default' } if ( bpmnElementSourceRef.value && bpmnElementSourceRef.value.default && - bpmnElementSourceRef.value.default.id === bpmnElement.value.id && - flowConditionForm.value.type == 'default' + bpmnElementSourceRef.value.default.id === bpmnElement.value.id ) { - // 默认 flowConditionForm.value = { type: 'default' } } else if (!bpmnElement.value.businessObject.conditionExpression) { // 普通 diff --git a/src/utils/download.ts b/src/utils/download.ts index ab200149..fe24ee27 100644 --- a/src/utils/download.ts +++ b/src/utils/download.ts @@ -29,7 +29,7 @@ const download = { html: (data: Blob, fileName: string) => { download0(data, fileName, 'text/html') }, - // 下载 Markdown 方法 + // 下载 MarkdownView 方法 markdown: (data: Blob, fileName: string) => { download0(data, fileName, 'text/markdown') } diff --git a/src/views/Login/SocialLogin.vue b/src/views/Login/SocialLogin.vue index 1eb9293c..eff59ba6 100644 --- a/src/views/Login/SocialLogin.vue +++ b/src/views/Login/SocialLogin.vue @@ -64,7 +64,7 @@ </el-form-item> </el-col> <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> - <el-form-item v-if="loginData.tenantEnable === 'true'" prop="tenantName"> + <el-form-item v-if="loginData.tenantEnable" prop="tenantName"> <el-input v-model="loginData.loginForm.tenantName" :placeholder="t('login.tenantNamePlaceholder')" @@ -207,7 +207,7 @@ const loginData = reactive({ // 获取验证码 const getCode = async () => { // 情况一,未开启:则直接登录 - if (loginData.captchaEnable) { + if (!loginData.captchaEnable) { await handleLogin({}) } else { // 情况二,已开启:则展示验证码;只有完成验证码的情况,才进行登录 diff --git a/src/views/ai/chat/components/ChatConversationUpdateForm.vue b/src/views/ai/chat/components/ChatConversationUpdateForm.vue new file mode 100644 index 00000000..fe08ffb2 --- /dev/null +++ b/src/views/ai/chat/components/ChatConversationUpdateForm.vue @@ -0,0 +1,141 @@ +<template> + <Dialog title="设定" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="130px" + v-loading="formLoading" + > + <el-form-item label="角色设定" prop="systemMessage"> + <el-input type="textarea" v-model="formData.systemMessage" placeholder="请输入角色设定" /> + </el-form-item> + <el-form-item label="模型" prop="modelId"> + <el-select v-model="formData.modelId" placeholder="请选择模型"> + <el-option + v-for="chatModel in chatModelList" + :key="chatModel.id" + :label="chatModel.name" + :value="chatModel.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" + /> + </el-form-item> + <el-form-item label="回复数 Token 数" prop="maxTokens"> + <el-input-number + v-model="formData.maxTokens" + placeholder="请输入回复数 Token 数" + :min="0" + :max="4096" + /> + </el-form-item> + <el-form-item label="上下文数量" prop="maxContexts"> + <el-input-number + v-model="formData.maxContexts" + placeholder="请输入上下文数量" + :min="0" + :max="20" + /> + </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> + </Dialog> +</template> +<script setup lang="ts"> +import { CommonStatusEnum } from '@/utils/constants' +import { ChatModelApi, ChatModelVO } from '@/api/ai/model/chatModel' +import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation' + +/** AI 聊天角色 表单 */ +defineOptions({ name: 'ChatConversationUpdateForm' }) + +const { t } = useI18n() // 国际化 +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 chatModelList = ref([] as ChatModelVO[]) // 聊天模型列表 + +/** 打开弹窗 */ +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 + } + } + // 获得下拉数据 + chatModelList.value = await ChatModelApi.getChatModelSimpleList(CommonStatusEnum.ENABLE) +} +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> diff --git a/src/views/ai/chat/components/Header.vue b/src/views/ai/chat/components/Header.vue new file mode 100644 index 00000000..7d859c2b --- /dev/null +++ b/src/views/ai/chat/components/Header.vue @@ -0,0 +1,50 @@ +<!-- header --> +<template> + <el-header class="chat-header"> + <div class="title"> + {{ title }} + </div> + <div class="title-right"> + <slot></slot> + </div> + </el-header> +</template> + +<script setup lang="ts"> +// 设置组件属性 +defineProps({ + title: { + type: String, + required: true + } +}) +</script> + +<style scoped lang="scss"> +.chat-header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 0 10px; + white-space: nowrap; + text-overflow: ellipsis; + background-color: #ececec; + width: 100%; + + .title { + font-size: 20px; + font-weight: bold; + overflow: hidden; + color: #3e3e3e; + max-width: 220px; + } + + .title-right { + display: flex; + flex-direction: row; + } +} + + +</style> diff --git a/src/views/ai/chat/components/MessageList.vue b/src/views/ai/chat/components/MessageList.vue new file mode 100644 index 00000000..0f77f8ec --- /dev/null +++ b/src/views/ai/chat/components/MessageList.vue @@ -0,0 +1,156 @@ + +<template> + <div class="message-container" ref="messageContainer"> + <div class="chat-list" v-for="(item, index) in list" :key="index"> + <!-- 靠左 message --> + <div class="left-message message-item" v-if="item.type === 'system'"> + <div class="avatar" > + <el-avatar + src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" + /> + </div> + <div class="message"> + <div> + <el-text class="time">{{formatDate(item.createTime)}}</el-text> + </div> + <div class="left-text-container"> + <el-text class="left-text"> + {{item.content}} + </el-text> + </div> + <div class="left-btns"> + <div class="btn-cus" @click="noCopy(item.content)"> + <img class="btn-image" src="@/assets/ai/copy.svg"/> + <el-text class="btn-cus-text">复制</el-text> + </div> + <div class="btn-cus" style="margin-left: 20px;" @click="onDelete(item.id)"> + <img class="btn-image" src="@/assets/ai/delete.svg" style="height: 17px;"/> + <el-text class="btn-cus-text">删除</el-text> + </div> + </div> + </div> + </div> + <!-- 靠右 message --> + <div class="right-message message-item" v-if="item.type === 'user'"> + <div class="avatar"> + <el-avatar + src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" + /> + </div> + <div class="message"> + <div> + <el-text class="time">{{formatDate(item.createTime)}}</el-text> + </div> + <div class="right-text-container"> + <el-text class="right-text"> + {{item.content}} + </el-text> + </div> + <div class="right-btns"> + <div class="btn-cus" @click="noCopy(item.content)"> + <img class="btn-image" src="@/assets/ai/copy.svg"/> + <el-text class="btn-cus-text">复制</el-text> + </div> + <div class="btn-cus" style="margin-left: 20px;" @click="onDelete(item.id)"> + <img class="btn-image" src="@/assets/ai/delete.svg" style="height: 17px;"/> + <el-text class="btn-cus-text">删除</el-text> + </div> + </div> + </div> + + </div> + </div> + </div> +</template> +<script setup lang="ts"> + +import { useClipboard } from '@vueuse/core' +import { ChatMessageApi, ChatMessageVO, ChatMessageSendVO} from '@/api/ai/chat/message' +import { formatDate } from '@/utils/formatTime' + +// 初始化 copy 到粘贴板 +const { copy, isSupported } = useClipboard(); +/** chat message 列表 */ +defineOptions({ name: 'chatMessageList' }) +const list = ref<ChatMessageVO[]>([]) // 列表的数据 + +// 对话id TODO @范 先写死 +const conversationId = '1781604279872581648' +const content = '苹果是什么颜色?' + +/** 查询列表 */ +const messageList = async () => { + try { + // 获取列表数据 + const res = await ChatMessageApi.messageList(conversationId) + list.value = res; + // 滚动到最下面 + scrollToBottom(); + } finally { + } +} +// ref +const messageContainer: any = ref(null); +const isScrolling = ref(false)//用于判断用户是否在滚动 + +function scrollToBottom() { + nextTick(() => { + //注意要使用nexttick以免获取不到dom + console.log('isScrolling.value', isScrolling.value) + if (!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) { + // 用户开始滚动并在最底部之上,取消保持在最底部的效果 + isScrolling.value = true + } else { + // 用户停止滚动并滚动到最底部,开启保持到最底部的效果 + isScrolling.value = false + } +} + +function noCopy(content) { + copy(content) + ElMessage({ + message: '复制成功!', + type: 'success', + }) +} + +const onDelete = async (id) => { + // 删除 message + await ChatMessageApi.delete(id) + ElMessage({ + message: '删除成功!', + type: 'success', + }) + // 重新获取 message 列表 + await messageList(); +} + +/** 初始化 **/ +onMounted(async () => { + // 获取列表数据 + messageList(); + + // scrollToBottom(); + // await nextTick + // 监听滚动事件,判断用户滚动状态 + messageContainer.value.addEventListener('scroll', handleScroll) + +}) +</script> + +<style scoped lang="scss"> + + +</style> diff --git a/src/views/ai/chat/index.vue b/src/views/ai/chat/index.vue index ec49b774..0da53f1e 100644 --- a/src/views/ai/chat/index.vue +++ b/src/views/ai/chat/index.vue @@ -4,8 +4,8 @@ <el-aside width="260px" class="conversation-container"> <div> <!-- 左顶部:新建对话 --> - <el-button class="w-1/1 btn-new-conversation" type="primary"> - <Icon icon="ep:plus" class="mr-5px" /> + <el-button class="w-1/1 btn-new-conversation" type="primary" @click="createConversation"> + <Icon icon="ep:plus" class="mr-5px"/> 新建对话 </el-button> <!-- 左顶部:搜索对话 --> @@ -17,45 +17,51 @@ @keyup="searchConversation" > <template #prefix> - <Icon icon="ep:search" /> + <Icon icon="ep:search"/> </template> </el-input> <!-- 左中间:对话列表 --> - <div class="conversation-list" > - <!-- TODO @芋艿,置顶、聊天记录、一星期钱、30天前,前端对数据重新做一下分组,或者后端接口改一下 --> - <div> - <el-text class="mx-1" size="small" tag="b">置顶</el-text> - </div> - <el-row v-for="conversation in conversationList" :key="conversation.id"> - <div - :class="conversation.id === conversationId ? 'conversation active' : 'conversation'" - @click="changeConversation(conversation)" - > - <div class="title-wrapper"> - <img class="avatar" :src="conversation.avatar" /> - <span class="title">{{ conversation.title }}</span> - </div> - <div class="button-wrapper"> - <el-icon title="编辑" @click="updateConversationTitle(conversation)"> - <Icon icon="ep:edit" /> - </el-icon> - <el-icon title="删除会话" @click="deleteConversationTitle(conversation)"> - <Icon icon="ep:delete" /> - </el-icon> - </div> + <div class="conversation-list"> + <!-- TODO @fain:置顶、聊天记录、一星期钱、30天前,前端对数据重新做一下分组,或者后端接口改一下 --> + <div v-for="conversationKey in Object.keys(conversationMap)" :key="conversationKey" > + <div v-if="conversationMap[conversationKey].length"> + <el-text class="mx-1" size="small" tag="b">{{conversationKey}}</el-text> </div> - </el-row> + <el-row + v-for="conversation in conversationMap[conversationKey]" + :key="conversation.id" + @click="handleConversationClick(conversation.id)"> + <div + :class="conversation.id === conversationId ? 'conversation active' : 'conversation'" + @click="changeConversation(conversation.id)" + > + <div class="title-wrapper"> + <img class="avatar" :src="conversation.roleAvatar"/> + <span class="title">{{ conversation.title }}</span> + </div> + <!-- TODO @fan:缺一个【置顶】按钮,效果改成 hover 上去展示 --> + <div class="button-wrapper"> + <el-icon title="编辑" @click="updateConversationTitle(conversation)"> + <Icon icon="ep:edit"/> + </el-icon> + <el-icon title="删除会话" @click="deleteChatConversation(conversation)"> + <Icon icon="ep:delete"/> + </el-icon> + </div> + </div> + </el-row> + </div> </div> </div> <!-- 左底部:工具栏 --> <div class="tool-box"> - <div> - <Icon icon="ep:user" /> + <div @click="handleRoleRepository"> + <Icon icon="ep:user"/> <el-text size="small">角色仓库</el-text> </div> - <div> - <Icon icon="ep:delete" /> - <el-text size="small" >清空未置顶对话</el-text> + <div @click="handleClearConversation"> + <Icon icon="ep:delete"/> + <el-text size="small">清空未置顶对话</el-text> </div> </div> </el-aside> @@ -64,73 +70,579 @@ <!-- 右顶部 TODO 芋艿:右对齐 --> <el-header class="header"> <div class="title"> - 标题...... + {{ useConversation?.title }} </div> <div> - <el-button>3.5-turbo-0125 <Icon icon="ep:setting" /></el-button> - <el-button> - <Icon icon="ep:user" /> + <!-- TODO @fan:样式改下;这里我已经改成点击后,弹出了 --> + <el-button type="primary" @click="openChatConversationUpdateForm"> + <span v-html="useConversation?.modelName"></span> + <Icon icon="ep:setting" style="margin-left: 10px"/> </el-button> <el-button> - <Icon icon="ep:download" /> + <Icon icon="ep:user"/> </el-button> <el-button> - <Icon icon="ep:arrow-up" /> + <Icon icon="ep:download"/> + </el-button> + <el-button> + <Icon icon="ep:arrow-up"/> </el-button> </div> </el-header> - <el-main>对话列表</el-main> - <el-footer> - <el-input - class="prompt-input" - type="textarea" - placeholder="请输入提示词..." - /> + + <!-- main --> + <el-main class="main-container"> + <div class="message-container" ref="messageContainer"> + <div class="chat-list" v-for="(item, index) in list" :key="index"> + <!-- 靠左 message --> + <!-- TODO 芋艿:类型判断 --> + <div class="left-message message-item" v-if="item.type === 'system'"> + <div class="avatar"> + <el-avatar + src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" + /> + </div> + <div class="message"> + <div> + <el-text class="time">{{ formatDate(item.createTime) }}</el-text> + </div> + <div class="left-text-container" ref="markdownViewRef"> +<!-- <div class="left-text markdown-view" v-html="item.content"></div>--> + <!-- <mdPreview :content="item.content" :delay="false" />--> + <MarkdownView class="left-text" :content="item.content" /> + </div> + <div class="left-btns"> + <div class="btn-cus" @click="noCopy(item.content)"> + <img class="btn-image" src="../../../assets/ai/copy.svg"/> + <el-text class="btn-cus-text">复制</el-text> + </div> + <div class="btn-cus" style="margin-left: 20px" @click="onDelete(item.id)"> + <img class="btn-image" src="@/assets/ai/delete.svg" style="height: 17px"/> + <el-text class="btn-cus-text">删除</el-text> + </div> + </div> + </div> + </div> + <!-- 靠右 message --> + <div class="right-message message-item" v-if="item.type === 'user'"> + <div class="avatar"> + <el-avatar + src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" + /> + </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> +<!-- <MarkdownView class="right-text" :content="item.content" />--> + </div> + <div class="right-btns"> + <div class="btn-cus" @click="noCopy(item.content)"> + <img class="btn-image" src="@/assets/ai/copy.svg"/> + <el-text class="btn-cus-text">复制</el-text> + </div> + <div class="btn-cus" style="margin-left: 20px" @click="onDelete(item.id)"> + <img class="btn-image" src="@/assets/ai/delete.svg" style="height: 17px"/> + <el-text class="btn-cus-text">删除</el-text> + </div> + </div> + </div> + </div> + </div> + </div> + <!-- 角色仓库抽屉 --> + <el-drawer v-model="drawer" title="角色仓库" size="50%"> + <Role/> + </el-drawer> + </el-main> + <el-footer class="footer-container"> + <form @submit.prevent="onSend" class="prompt-from"> + <textarea + class="prompt-input" + v-model="prompt" + @keyup.enter="onSend" + @input="onPromptInput" + @compositionstart="onCompositionstart" + @compositionend="onCompositionend" + placeholder="问我任何问题...(Shift+Enter 换行,按下 Enter 发送)" + ></textarea> + <div class="prompt-btns"> + <el-switch/> + <el-button + type="primary" + size="default" + @click="onSend()" + :loading="conversationInProgress" + v-if="conversationInProgress == false" + > + {{ conversationInProgress ? '进行中' : '发送' }} + </el-button> + <el-button + type="danger" + size="default" + @click="stopStream()" + v-if="conversationInProgress == true" + > + 停止 + </el-button> + </div> + </form> </el-footer> </el-container> </el-container> + + <ChatConversationUpdateForm + ref="chatConversationUpdateFormRef" + @success="getChatConversationList" + /> </template> + <script setup lang="ts"> -const conversationList = [ - { - id: 1, - title: '测试标题', - avatar: - 'http://test.yudao.iocoder.cn/96c787a2ce88bf6d0ce3cd8b6cf5314e80e7703cd41bf4af8cd2e2909dbd6b6d.png' - }, - { - id: 2, - title: '测试对话', - avatar: - 'http://test.yudao.iocoder.cn/96c787a2ce88bf6d0ce3cd8b6cf5314e80e7703cd41bf4af8cd2e2909dbd6b6d.png' +import MarkdownView from '@/components/MarkdownView/index.vue' +import {ChatMessageApi, ChatMessageVO} from '@/api/ai/chat/message' +import {ChatConversationApi, ChatConversationVO} from '@/api/ai/chat/conversation' +import ChatConversationUpdateForm from './components/ChatConversationUpdateForm.vue' +import Role from '@/views/ai/chat/role/index.vue' +import {formatDate} from '@/utils/formatTime' +import {useClipboard} from '@vueuse/core' + +const route = useRoute() // 路由 +const message = useMessage() // 消息弹窗 + +const conversationList = ref([] as ChatConversationVO[]) +const conversationMap = ref<any>({}) +// 初始化 copy 到粘贴板 +const {copy} = useClipboard() + +const drawer = ref<boolean>(false) // 角色仓库抽屉 +const searchName = ref('') // 查询的内容 +const inputTimeout = ref<any>() // 处理输入中回车的定时器 +const conversationId = ref<number | null>(null) // 选中的对话编号 +const conversationInProgress = ref(false) // 对话进行中 +const conversationInAbortController = ref<any>() // 对话进行中 abort 控制器(控制 stream 对话) + +const prompt = ref<string>() // prompt + +// 判断 消息列表 滚动的位置(用于判断是否需要滚动到消息最下方) +const messageContainer: any = ref(null) +const isScrolling = ref(false) //用于判断用户是否在滚动 +const isComposing = ref(false) // 判断用户是否在输入 + +/** chat message 列表 */ +// defineOptions({ name: 'chatMessageList' }) +const list = ref<ChatMessageVO[]>([]) // 列表的数据 +const useConversation = ref<ChatConversationVO | null>(null) // 使用的 Conversation + +/** 新建对话 */ +const createConversation = async () => { + // 新建对话 + const conversationId = await ChatConversationApi.createChatConversationMy( + {} as unknown as ChatConversationVO + ) + changeConversation(conversationId) + // 刷新对话列表 + await getChatConversationList() +} + +const changeConversation = (id: number) => { + // 切换对话 + conversationId.value = id + // TODO 芋艿:待实现 + // 刷新 message 列表 + messageList() +} + +/** 更新聊天会话的标题 */ +const updateConversationTitle = async (conversation: ChatConversationVO) => { + // 二次确认 + const {value} = await ElMessageBox.prompt('修改标题', { + inputPattern: /^[\s\S]*.*\S[\s\S]*$/, // 判断非空,且非空格 + inputErrorMessage: '标题不能为空', + inputValue: conversation.title + }) + // 发起修改 + await ChatConversationApi.updateChatConversationMy({ + id: conversation.id, + title: value + } as ChatConversationVO) + message.success('重命名成功') + // 刷新列表 + await getChatConversationList() +} + +/** 删除聊天会话 */ +const deleteChatConversation = async (conversation: ChatConversationVO) => { + try { + // 删除的二次确认 + await message.delConfirm(`是否确认删除会话 - ${conversation.title}?`) + // 发起删除 + await ChatConversationApi.deleteChatConversationMy(conversation.id) + message.success('会话已删除') + // 刷新列表 + await getChatConversationList() + } catch { } -] -const conversationId = ref(1) -const searchName = ref('') -const leftHeight = window.innerHeight - 240 // TODO 芋艿:这里还不太对 - -const changeConversation = (conversation) => { - console.log(conversation) - conversationId.value = conversation.id - // TODO 芋艿:待实现 -} - -const updateConversationTitle = (conversation) => { - console.log(conversation) - // TODO 芋艿:待实现 -} - -const deleteConversationTitle = (conversation) => { - console.log(conversation) - // TODO 芋艿:待实现 } const searchConversation = () => { - // TODO 芋艿:待实现 + // TODO fan:待实现 } + +/** send */ +const onSend = async () => { + // 判断用户是否在输入 + if (isComposing.value) { + return + } + // 进行中不允许发送 + if (conversationInProgress.value) { + return + } + const content = prompt.value?.trim() + '' + if (content.length < 2) { + ElMessage({ + message: '请输入内容!', + type: 'error' + }) + return + } + // TODO 芋艿:这块交互要在优化;应该是先插入到 UI 界面,里面会有当前的消息,和正在思考中;之后发起请求; + // 清空输入框 + prompt.value = '' + // const requestParams = { + // conversationId: conversationId.value, + // content: content + // } as unknown as ChatMessageSendVO + // // 添加 message + const userMessage = { + conversationId: conversationId.value, + content: content + } as ChatMessageVO + // list.value.push(userMessage) + // // 滚动到住下面 + // scrollToBottom() + // stream + await doSendStream(userMessage) + // +} + +const doSendStream = async (userMessage: ChatMessageVO) => { + // 创建AbortController实例,以便中止请求 + conversationInAbortController.value = new AbortController() + // 标记对话进行中 + conversationInProgress.value = true + try { + // 发送 event stream + let isFirstMessage = true + let content = '' + ChatMessageApi.sendStream( + userMessage.conversationId, // TODO 芋艿:这里可能要在优化; + userMessage.content, + conversationInAbortController.value, + (message) => { + console.log('message', message) + const data = JSON.parse(message.data) // TODO 芋艿:类型处理; + // debugger + // 如果没有内容结束链接 + if (data.receive.content === '') { + // 标记对话结束 + conversationInProgress.value = false + // 结束 stream 对话 + conversationInAbortController.value.abort() + } + // 首次返回需要添加一个 message 到页面,后面的都是更新 + if (isFirstMessage) { + isFirstMessage = false + // debugger + list.value.push(data.send) + list.value.push(data.receive) + } else { + // debugger + content = content + data.receive.content + const lastMessage = list.value[list.value.length - 1] + lastMessage.content = content + list.value[list.value - 1] = lastMessage + } + // 滚动到最下面 + scrollToBottom() + }, + (error) => { + console.log('error', error) + // 标记对话结束 + conversationInProgress.value = false + // 结束 stream 对话 + conversationInAbortController.value.abort() + }, + () => { + console.log('close') + // 标记对话结束 + conversationInProgress.value = false + // 结束 stream 对话 + conversationInAbortController.value.abort() + } + ) + } finally { + } +} + +/** 查询列表 */ +const messageList = async () => { + try { + if (conversationId.value === null) { + return + } + // 获取列表数据 + const res = await ChatMessageApi.messageList(conversationId.value) + list.value = res + + // 滚动到最下面 + scrollToBottom() + } finally { + } +} + +function scrollToBottom() { + nextTick(() => { + //注意要使用nexttick以免获取不到dom + console.log('isScrolling.value', isScrolling.value) + if (!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) { + // 用户开始滚动并在最底部之上,取消保持在最底部的效果 + isScrolling.value = true + } else { + // 用户停止滚动并滚动到最底部,开启保持到最底部的效果 + isScrolling.value = false + } +} + +function noCopy(content) { + copy(content) + ElMessage({ + message: '复制成功!', + type: 'success' + }) +} + +const onDelete = async (id) => { + // 删除 message + await ChatMessageApi.delete(id) + ElMessage({ + message: '删除成功!', + type: 'success' + }) + // tip:如果 stream 进行中的 message,就需要调用 controller 结束 + stopStream() + // 重新获取 message 列表 + await messageList() +} + +const stopStream = async () => { + // tip:如果 stream 进行中的 message,就需要调用 controller 结束 + if (conversationInAbortController.value) { + conversationInAbortController.value.abort() + } + // 设置为 false + conversationInProgress.value = false +} + +/** 修改聊天会话 */ +const chatConversationUpdateFormRef = ref() +const openChatConversationUpdateForm = async () => { + chatConversationUpdateFormRef.value.open(conversationId.value) +} + +// 输入 +const onCompositionstart = () => { + console.log('onCompositionstart。。。.') + isComposing.value = true +} + +const onCompositionend = () => { + // console.log('输入结束...') + setTimeout(() => { + console.log('输入结束...') + isComposing.value = false + }, 200) +} + +const onPromptInput = (event) => { + // 非输入法 输入设置为 true + if (!isComposing.value) { + // 回车 event data 是 null + if (event.data == null) { + return + } + console.log('setTimeout 输入开始...') + isComposing.value = true + } + // 清理定时器 + if (inputTimeout.value) { + clearTimeout(inputTimeout.value) + } + // 重置定时器 + inputTimeout.value = setTimeout(() => { + console.log('setTimeout 输入结束...') + isComposing.value = false + }, 400) +} + +const getConversation = async (conversationId: number | null) => { + if (!conversationId) { + return + } + // 获取对话信息 + useConversation.value = await ChatConversationApi.getChatConversationMy(conversationId) + console.log('useConversation.value', useConversation.value) +} + +/** 获得聊天会话列表 */ +const getChatConversationList = async () => { + conversationList.value = await ChatConversationApi.getChatConversationMyList() + // 默认选中第一条 + if (conversationList.value.length === 0) { + conversationId.value = null + list.value = [] + } else { + if (conversationId.value === null) { + conversationId.value = conversationList.value[0].id + changeConversation(conversationList.value[0].id) + } + } + // map + const groupRes = await conversationTimeGroup(conversationList.value) + conversationMap.value = groupRes +} + +const conversationTimeGroup = async (list: ChatConversationVO[]) => { + // 排序、指定、时间分组(今天、一天前、三天前、七天前、30天前) + const groupMap = { + '置顶': [], + '今天': [], + '一天前': [], + '三天前': [], + '七天前': [], + '三十天前': [] + } + // 当前时间的时间戳 + const now = Date.now(); + // 定义时间间隔常量(单位:毫秒) + const oneDay = 24 * 60 * 60 * 1000; + const threeDays = 3 * oneDay; + const sevenDays = 7 * oneDay; + const thirtyDays = 30 * oneDay; + console.log('listlistlist', list) + for (const conversation: ChatConversationVO of list) { + // 置顶 + if (conversation.pinned) { + groupMap['置顶'].push(conversation) + continue + } + // 计算时间差(单位:毫秒) + const diff = now - conversation.updateTime; + // 根据时间间隔判断 + 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 handleConversationClick = async (id: number) => { + // 切换对话 + conversationId.value = id + console.log('conversationId.value', conversationId.value) + // 获取列表数据 + await messageList() +} + +// 角色仓库 +const handleRoleRepository = async () => { + drawer.value = !drawer.value +} + +// 清空对话 +const handleClearConversation = async () => { + ElMessageBox.confirm( + '确认后对话会全部清空,置顶的对话除外。', + '确认提示', + { + confirmButtonText: '确认', + cancelButtonText: '取消', + type: 'warning', + } + ) + .then(async () => { + await ChatConversationApi.deleteMyAllExceptPinned() + ElMessage({ + message: '操作成功!', + type: 'success' + }) + // 清空选中的对话 + useConversation.value = null + conversationId.value = null + list.value = [] + // 获得聊天会话列表 + await getChatConversationList() + }) + .catch(() => { + }) +} + + +/** 初始化 **/ +onMounted(async () => { + // 设置当前对话 + if (route.query.conversationId) { + conversationId.value = route.query.conversationId as number + } + // 获得聊天会话列表 + await getChatConversationList() + // 获取对话信息 + await getConversation(conversationId.value) + // 获取列表数据 + await messageList() + // scrollToBottom(); + // await nextTick + // 监听滚动事件,判断用户滚动状态 + messageContainer.value.addEventListener('scroll', handleScroll) + // 添加 copy 监听 + messageContainer.value.addEventListener('click', (e: any) => { + console.log(e) + if (e.target.id === 'copy') { + copy(e.target?.dataset?.copy) + ElMessage({ + message: '复制成功!', + type: 'success' + }) + } + }) +}) </script> <style lang="scss" scoped> - .ai-layout { // TODO @范 这里height不能 100% 先这样临时处理 position: absolute; @@ -172,8 +684,10 @@ const searchConversation = () => { border-radius: 5px; align-items: center; line-height: 30px; + &.active { background-color: #e6e6e6; + .button { display: inline-block; } @@ -184,6 +698,7 @@ const searchConversation = () => { flex-direction: row; align-items: center; } + .title { padding: 5px 10px; max-width: 220px; @@ -192,6 +707,7 @@ const searchConversation = () => { white-space: nowrap; text-overflow: ellipsis; } + .avatar { width: 28px; height: 28px; @@ -199,6 +715,7 @@ const searchConversation = () => { flex-direction: row; justify-items: center; } + // 对话编辑、删除 .button-wrapper { right: 2px; @@ -206,6 +723,7 @@ const searchConversation = () => { flex-direction: row; justify-items: center; color: #606266; + .el-icon { margin-right: 5px; } @@ -227,6 +745,8 @@ const searchConversation = () => { color: #606266; padding: 0; margin: 0; + cursor: pointer; + > span { margin-left: 5px; } @@ -234,6 +754,7 @@ const searchConversation = () => { } } +// 头部 .detail-container { background: #ffffff; @@ -243,16 +764,176 @@ const searchConversation = () => { align-items: center; justify-content: space-between; background: #fbfbfb; + box-shadow: 0 0 0 0 #dcdfe6; .title { - font-size: 23px; + font-size: 18px; font-weight: bold; } } +} +// main 容器 +.main-container { + margin: 0; + padding: 0; + position: relative; +} + +.message-container { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + //width: 100%; + //height: 100%; + overflow-y: scroll; + padding: 0 15px; +} + +// 中间 +.chat-list { + display: flex; + flex-direction: column; + overflow-y: hidden; + + .message-item { + margin-top: 50px; + } + + .left-message { + display: flex; + flex-direction: row; + } + + .right-message { + display: flex; + flex-direction: row-reverse; + justify-content: flex-start; + } + + .avatar { + //height: 170px; + //width: 170px; + } + + .message { + display: flex; + flex-direction: column; + text-align: left; + margin: 0 15px; + + .time { + text-align: left; + line-height: 30px; + } + + .left-text-container { + display: flex; + flex-direction: column; + overflow-wrap: break-word; + background-color: rgba(228, 228, 228, 0.8); + box-shadow: 0 0 0 1px rgba(228, 228, 228, 0.8); + border-radius: 10px; + padding: 10px 10px 5px 10px; + + .left-text { + color: #393939; + font-size: 0.95rem; + } + } + + .right-text-container { + display: flex; + flex-direction: row-reverse; + + .right-text { + font-size: 0.95rem; + color: #fff; + display: inline; + background-color: #267fff; + color: #fff; + box-shadow: 0 0 0 1px #267fff; + border-radius: 10px; + padding: 10px; + width: auto; + overflow-wrap: break-word; + } + } + + .left-btns, + .right-btns { + display: flex; + flex-direction: row; + margin-top: 8px; + } + } + + // 复制、删除按钮 + .btn-cus { + display: flex; + background-color: transparent; + align-items: center; + + .btn-image { + height: 20px; + margin-right: 5px; + } + + .btn-cus-text { + color: #757575; + } + } + + .btn-cus:hover { + cursor: pointer; + } + + .btn-cus:focus { + background-color: #8c939d; + } +} + +// 底部 +.footer-container { + display: flex; + flex-direction: column; + height: auto; + margin: 0; + padding: 0; + + .prompt-from { + display: flex; + flex-direction: column; + height: auto; + border: 1px solid #e3e3e3; + border-radius: 10px; + margin: 20px 20px; + padding: 9px 10px; + } .prompt-input { + height: 80px; + //box-shadow: none; + border: none; + box-sizing: border-box; + resize: none; + padding: 0px 2px; + //padding: 5px 5px; + overflow: hidden; + } + + .prompt-input:focus { + outline: none; + } + + .prompt-btns { + display: flex; + justify-content: space-between; + padding-bottom: 0px; + padding-top: 5px; } } </style> diff --git a/src/views/ai/chat/role/RoleCategoryList.vue b/src/views/ai/chat/role/RoleCategoryList.vue new file mode 100644 index 00000000..549a4416 --- /dev/null +++ b/src/views/ai/chat/role/RoleCategoryList.vue @@ -0,0 +1,46 @@ +<template> + <div class="category-list"> + <div class="category" v-for="category in categoryList" :key="category"> + <el-button plain v-if="category !== active" @click="handleCategoryClick(category)">{{ category }}</el-button> + <el-button plain type="primary" v-else @click="handleCategoryClick(category)">{{ category }}</el-button> + </div> + </div> +</template> +<script setup lang="ts"> +import {PropType} from "vue"; + +// 定义属性 +defineProps({ + categoryList: { + type: Array as PropType<string[]>, + required: true + }, + active: { + type: String, + required: false + } +}) + +// 定义回调 +const emits = defineEmits(['onCategoryClick']) + +// 处理分类点击事件 +const handleCategoryClick = async (category) => { + emits('onCategoryClick', category) +} + +</script> +<style scoped lang="scss"> +.category-list { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + + .category { + display: flex; + flex-direction: row; + margin-right: 20px; + } +} +</style> diff --git a/src/views/ai/chat/role/RoleList.vue b/src/views/ai/chat/role/RoleList.vue new file mode 100644 index 00000000..834eb3cb --- /dev/null +++ b/src/views/ai/chat/role/RoleList.vue @@ -0,0 +1,155 @@ +<template> + <div class="card-list"> + <el-card class="card" body-class="card-body" v-for="role in roleList" :key="role.id"> + <!-- 更多 --> + <div class="more-container"> + <el-dropdown @command="handleMoreClick"> + <span class="el-dropdown-link"> + <el-button type="text" > + <el-icon><More /></el-icon> + </el-button> + </span> + <template #dropdown> + <el-dropdown-menu> + <el-dropdown-item :command="['edit', role]" > + <el-icon><EditPen /></el-icon>编辑 + </el-dropdown-item> + <el-dropdown-item :command="['delete', role]" style="color: red;" > + <el-icon><Delete /></el-icon> + <span>删除</span> + </el-dropdown-item> + </el-dropdown-menu> + </template> + </el-dropdown> + </div> + <!-- 头像 --> + <div> + <img class="avatar" :src="role.avatar"/> + </div> + <div class="right-container"> + <div class="content-container"> + <div class="title">{{ role.name }}</div> + <div class="description">{{ role.description }}</div> + + </div> + <div class="btn-container"> + <el-button type="primary" size="small" @click="handleUseClick(role)">使用</el-button> + </div> + </div> + </el-card> + </div> +</template> + +<script setup lang="ts"> +import {ChatRoleVO} from '@/api/ai/model/chatRole' +import {PropType} from "vue"; +import {Delete, EditPen, More} from "@element-plus/icons-vue"; + +// 定义属性 +const props = defineProps({ + roleList: { + type: Array as PropType<ChatRoleVO[]>, + required: true + } +}) +// 定义钩子 +const emits = defineEmits(['onDelete', 'onEdit', 'onUse']) + +// more 点击 +const handleMoreClick = async (data) => { + const type = data[0] + const role = data[1] + if (type === 'delete') { + emits('onDelete', role) + } else { + emits('onEdit', role) + } +} + +// 使用 +const handleUseClick = (role) => { + emits('onUse', role) +} + +onMounted(() => { + console.log('props', props.roleList) +}) + +</script> + +<style lang="scss"> +// 重写 card 组件 body 样式 +.card-body { + max-width: 300px; + width: 300px; + padding: 15px; + + display: flex; + flex-direction: row; + justify-content: flex-start; + position: relative; + +} +</style> +<style scoped lang="scss"> + +// 卡片列表 +.card-list { + display: flex; + flex-direction: row; + flex-wrap: wrap; + position: relative; + + .card { + margin-right: 20px; + border-radius: 10px; + margin-bottom: 30px; + position: relative; + + .more-container { + position: absolute; + right: 12px; + top: 0px; + } + + .avatar { + width: 40px; + height: 40px; + border-radius: 10px; + overflow: hidden; + } + + .right-container { + margin-left: 10px; + width: 100%; + //height: 100px; + + .content-container { + height: 85px; + overflow: hidden; + + .title { + font-size: 18px; + font-weight: bold; + color: #3e3e3e; + } + + .description { + margin-top: 10px; + font-size: 14px; + color: #6a6a6a; + } + } + + .btn-container { + display: flex; + flex-direction: row-reverse; + margin-top: 15px; + } + } + + } + +} + +</style> diff --git a/src/views/ai/chat/role/index.vue b/src/views/ai/chat/role/index.vue new file mode 100644 index 00000000..924a6784 --- /dev/null +++ b/src/views/ai/chat/role/index.vue @@ -0,0 +1,219 @@ +<!-- chat 角色仓库 --> +<template> + <el-container class="role-container"> + <ChatRoleForm ref="formRef" @success="handlerAddRoleSuccess" /> + + <Header title="角色仓库"/> + <el-main class="role-main"> + <div class="search-container"> + <!-- 搜索按钮 --> + <el-input + v-model="search" + class="search-input" + size="default" + placeholder="请输入搜索的内容" + :suffix-icon="Search" + @change="getActiveTabsRole" + /> + <el-button type="primary" @click="handlerAddRole" style="margin-left: 20px;"> + <el-icon><User /></el-icon> + 添加角色 + </el-button> + </div> + <!-- tabs --> + <el-tabs v-model="activeRole" class="tabs" @tab-click="handleTabsClick"> + <el-tab-pane class="role-pane" label="我的角色" name="my-role"> + <RoleList :role-list="myRoleList" @onDelete="handlerCardDelete" @onEdit="handlerCardEdit" @onUse="handlerCardUse" style="margin-top: 20px;" /> + </el-tab-pane> + <el-tab-pane label="公共角色" name="public-role"> + <RoleCategoryList :category-list="categoryList" :active="activeCategory" @onCategoryClick="handlerCategoryClick" /> + <RoleList :role-list="publicRoleList" @onDelete="handlerCardDelete" @onEdit="handlerCardEdit" style="margin-top: 20px;" /> + </el-tab-pane> + </el-tabs> + </el-main> + </el-container> + +</template> + +<!-- setup --> +<script setup lang="ts"> +import {ref} from "vue"; +import Header from '@/views/ai/chat/components/Header.vue' +import RoleList from './RoleList.vue' +import ChatRoleForm from '@/views/ai/model/chatRole/ChatRoleForm.vue' +import RoleCategoryList from './RoleCategoryList.vue' +import {ChatRoleApi, ChatRolePageReqVO, ChatRoleVO} from '@/api/ai/model/chatRole' +import {ChatConversationApi, ChatConversationVO} from '@/api/ai/chat/conversation' +import {TabsPaneContext} from "element-plus"; +import {Search, User} from "@element-plus/icons-vue"; + +// 获取路由 +const router = useRouter() + +// 属性定义 +const activeRole = ref<string>('my-role') // 选中的角色 +const loadding = ref<boolean>(true) // 加载中 +const search = ref<string>('') // 加载中 +const myPageNo = ref<number>(1) // my 分页下标 +const myPageSize = ref<number>(50) // my 分页大小 +const myRoleList = ref<ChatRoleVO[]>([]) // my 分页大小 +const publicPageNo = ref<number>(1) // public 分页下标 +const publicPageSize = ref<number>(50) // public 分页大小 +const publicRoleList = ref<ChatRoleVO[]>([]) // public 分页大小 +const activeCategory = ref<string>('') // 选择中的分类 +const categoryList = ref<string[]>([]) // 角色分类类别 +/** 添加/修改操作 */ +const formRef = ref() +// tabs 点击 +const handleTabsClick = async (tab: TabsPaneContext) => { + // 设置切换状态 + const activeTabs = tab.paneName + '' + activeRole.value = activeTabs; + // 切换的时候重新加载数据 + await getActiveTabsRole() +} + +// 获取 my role +const getMyRole = async () => { + const params:ChatRolePageReqVO = { + pageNo: myPageNo.value, + pageSize: myPageSize.value, + category: activeCategory.value, + name: search.value, + publicStatus: false + } + const { total, list } = await ChatRoleApi.getMyPage(params) + myRoleList.value = list +} + +// 获取 public role +const getPublicRole = async () => { + const params:ChatRolePageReqVO = { + pageNo: publicPageNo.value, + pageSize: publicPageSize.value, + category: activeCategory.value, + name: search.value, + publicStatus: true + } + const { total, list } = await ChatRoleApi.getMyPage(params) + publicRoleList.value = list +} + +// 获取选中的 tabs 角色 +const getActiveTabsRole = async () => { + if (activeRole.value === 'my-role') { + await getMyRole() + } else { + await getPublicRole() + } +} + +// 获取角色分类列表 +const getRoleCategoryList = async () => { + categoryList.value = await ChatRoleApi.getCategoryList() +} + +// 处理分类点击 +const handlerCategoryClick = async (category: string) => { + if (activeCategory.value === category) { + activeCategory.value = '' + } else { + activeCategory.value = category + } + await getActiveTabsRole() +} + +// 添加角色 +const handlerAddRole = async () => { + formRef.value.open('my-create', null, '添加角色') +} + +// card 删除 +const handlerCardDelete = async (role) => { + await ChatRoleApi.deleteMy(role.id) + // 刷新数据 + await getActiveTabsRole() +} + +// card 编辑 +const handlerCardEdit = async (role) => { + formRef.value.open('my-update', role.id, '编辑角色') +} + +// card 使用 +const handlerCardUse = async (role) => { + const data : ChatConversationVO = { + roleId: role.id + } as unknown as ChatConversationVO + // 创建对话 + const conversation = await ChatConversationApi.createChatConversationMy(data) + // 调整页面 + router.push({ + path: `/ai/chat/index`, + query: { + conversationId: conversation, + } + }) +} + +// 添加角色成功 +const handlerAddRoleSuccess = async (e) => { + console.log(e) + // 刷新数据 + await getActiveTabsRole() +} + +// +onMounted( async () => { + // 获取分类 + await getRoleCategoryList() + // 获取 role 数据 + await getActiveTabsRole() +}) +</script> +<!-- 样式 --> +<style scoped lang="scss"> + +// 跟容器 +.role-container { + position: absolute; + margin: 0; + padding: 0; + width: 100%; + height: 100%; + left: 0; + right: 0; + top: 0; + bottom: 0; + background-color: #ffffff; + + display: flex; + flex-direction: column; + + .role-main { + position: relative; + + .search-container { + //position: absolute; + //right: 20px; + //top: 10px; + //z-index: 100; + } + + .search-input { + width: 240px; + } + + .tabs { + position: relative; + } + + .role-pane { + display: flex; + flex-direction: column; + } + } +} + + +</style> diff --git a/src/views/ai/model/apiKey/ApiKeyForm.vue b/src/views/ai/model/apiKey/ApiKeyForm.vue index 2f9ba580..a8fc0128 100644 --- a/src/views/ai/model/apiKey/ApiKeyForm.vue +++ b/src/views/ai/model/apiKey/ApiKeyForm.vue @@ -7,13 +7,7 @@ label-width="120px" v-loading="formLoading" > - <el-form-item label="名称" prop="name"> - <el-input v-model="formData.name" placeholder="请输入名称" /> - </el-form-item> - <el-form-item label="密钥" prop="apiKey"> - <el-input v-model="formData.apiKey" placeholder="请输入密钥" /> - </el-form-item> - <el-form-item label="平台" prop="platform"> + <el-form-item label="所属平台" prop="platform"> <el-select v-model="formData.platform" placeholder="请输入平台" clearable> <el-option v-for="dict in getStrDictOptions(DICT_TYPE.AI_PLATFORM)" @@ -23,8 +17,14 @@ /> </el-select> </el-form-item> - <el-form-item label="自定义 API 地址" prop="url"> - <el-input v-model="formData.url" placeholder="请输入自定义 API 地址" /> + <el-form-item label="名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入名称" /> + </el-form-item> + <el-form-item label="密钥" prop="apiKey"> + <el-input v-model="formData.apiKey" placeholder="请输入密钥" /> + </el-form-item> + <el-form-item label="自定义 API URL" prop="url"> + <el-input v-model="formData.url" placeholder="请输入自定义 API URL" /> </el-form-item> <el-form-item label="状态" prop="status"> <el-radio-group v-model="formData.status"> diff --git a/src/views/ai/model/apiKey/index.vue b/src/views/ai/model/apiKey/index.vue index fcafc463..6daf6a7d 100644 --- a/src/views/ai/model/apiKey/index.vue +++ b/src/views/ai/model/apiKey/index.vue @@ -60,15 +60,14 @@ <!-- 列表 --> <ContentWrap> <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> - <el-table-column label="编号" align="center" prop="id" /> - <el-table-column label="名称" align="center" prop="name" /> - <el-table-column label="密钥" align="center" prop="apiKey" /> - <el-table-column label="平台" align="center" prop="platform"> + <el-table-column label="所属平台" align="center" prop="platform"> <template #default="scope"> <dict-tag :type="DICT_TYPE.AI_PLATFORM" :value="scope.row.platform" /> </template> </el-table-column> - <el-table-column label="自定义 API 地址" align="center" prop="url" /> + <el-table-column label="名称" align="center" prop="name" /> + <el-table-column label="密钥" align="center" prop="apiKey" /> + <el-table-column label="自定义 API URL" align="center" prop="url" /> <el-table-column label="状态" align="center" prop="status"> <template #default="scope"> <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> diff --git a/src/views/ai/model/chatModel/ChatModelForm.vue b/src/views/ai/model/chatModel/ChatModelForm.vue new file mode 100644 index 00000000..cac3f772 --- /dev/null +++ b/src/views/ai/model/chatModel/ChatModelForm.vue @@ -0,0 +1,165 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="120px" + v-loading="formLoading" + > + <el-form-item label="所属平台" prop="platform"> + <el-select v-model="formData.platform" placeholder="请输入平台" clearable> + <el-option + v-for="dict in getStrDictOptions(DICT_TYPE.AI_PLATFORM)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="API 秘钥" prop="keyId"> + <el-select v-model="formData.keyId" placeholder="请选择 API 秘钥" clearable> + <el-option + v-for="apiKey in apiKeyList" + :key="apiKey.id" + :label="apiKey.name" + :value="apiKey.id" + /> + </el-select> + </el-form-item> + <el-form-item label="模型名字" prop="name"> + <el-input v-model="formData.name" placeholder="请输入模型名字" /> + </el-form-item> + <el-form-item label="模型标识" prop="model"> + <el-input v-model="formData.model" placeholder="请输入模型标识" /> + </el-form-item> + <el-form-item label="模型排序" prop="sort"> + <el-input-number v-model="formData.sort" placeholder="请输入模型排序" class="!w-1/1" /> + </el-form-item> + <el-form-item label="开启状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="温度参数" prop="temperature"> + <el-input v-model="formData.temperature" placeholder="请输入温度参数" /> + </el-form-item> + <el-form-item label="回复数 Token 数" prop="maxTokens"> + <el-input v-model="formData.maxTokens" placeholder="请输入回复数 Token 数" /> + </el-form-item> + <el-form-item label="上下文数量" prop="maxContexts"> + <el-input v-model="formData.maxContexts" placeholder="请输入上下文数量" /> + </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> + </Dialog> +</template> +<script setup lang="ts"> +import { ChatModelApi, ChatModelVO } from '@/api/ai/model/chatModel' +import { ApiKeyApi, ApiKeyVO } from '@/api/ai/model/apiKey' +import { CommonStatusEnum } from '@/utils/constants' +import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict' + +/** API 聊天模型 表单 */ +defineOptions({ name: 'ChatModelForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + keyId: undefined, + name: undefined, + model: undefined, + platform: undefined, + sort: undefined, + status: CommonStatusEnum.ENABLE, + temperature: undefined, + maxTokens: undefined, + maxContexts: undefined +}) +const formRules = reactive({ + keyId: [{ required: true, message: 'API 秘钥不能为空', trigger: 'blur' }], + name: [{ required: true, message: '模型名字不能为空', trigger: 'blur' }], + model: [{ required: true, message: '模型标识不能为空', trigger: 'blur' }], + platform: [{ required: true, message: '所属平台不能为空', trigger: 'blur' }], + sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref +const apiKeyList = ref([] as ApiKeyVO[]) // API 密钥列表 + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ChatModelApi.getChatModel(id) + } finally { + formLoading.value = false + } + } + // 获得下拉数据 + apiKeyList.value = await ApiKeyApi.getApiKeySimpleList(CommonStatusEnum.ENABLE) +} +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 ChatModelVO + if (formType.value === 'create') { + await ChatModelApi.createChatModel(data) + message.success(t('common.createSuccess')) + } else { + await ChatModelApi.updateChatModel(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + keyId: undefined, + name: undefined, + model: undefined, + platform: undefined, + sort: undefined, + status: CommonStatusEnum.ENABLE, + temperature: undefined, + maxTokens: undefined, + maxContexts: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/ai/model/chatModel/index.vue b/src/views/ai/model/chatModel/index.vue new file mode 100644 index 00000000..c5506746 --- /dev/null +++ b/src/views/ai/model/chatModel/index.vue @@ -0,0 +1,185 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="模型名字" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入模型名字" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="模型标识" prop="model"> + <el-input + v-model="queryParams.model" + placeholder="请输入模型标识" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="模型平台" prop="platform"> + <el-input + v-model="queryParams.platform" + placeholder="请输入模型平台" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['ai:chat-model:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="所属平台" align="center" prop="platform"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.AI_PLATFORM" :value="scope.row.platform" /> + </template> + </el-table-column> + <el-table-column label="模型名字" align="center" prop="name" /> + <el-table-column label="模型标识" align="center" prop="model" /> + <el-table-column label="API 秘钥" align="center" prop="keyId" min-width="140"> + <template #default="scope"> + <span>{{ apiKeyList.find((item) => item.id === scope.row.keyId)?.name }}</span> + </template> + </el-table-column> + <el-table-column label="排序" align="center" prop="sort" /> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="温度参数" align="center" prop="temperature" /> + <el-table-column label="回复数 Token 数" align="center" prop="maxTokens" min-width="140" /> + <el-table-column label="上下文数量" align="center" prop="maxContexts" /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['ai:chat-model:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['ai:chat-model:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ChatModelForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { ChatModelApi, ChatModelVO } from '@/api/ai/model/chatModel' +import ChatModelForm from './ChatModelForm.vue' +import { DICT_TYPE } from '@/utils/dict' +import { ApiKeyApi, ApiKeyVO } from '@/api/ai/model/apiKey' + +/** API 聊天模型 列表 */ +defineOptions({ name: 'AiChatModel' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<ChatModelVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + model: undefined, + platform: undefined +}) +const queryFormRef = ref() // 搜索的表单 +const apiKeyList = ref([] as ApiKeyVO[]) // API 密钥列表 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ChatModelApi.getChatModelPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ChatModelApi.deleteChatModel(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(async () => { + getList() + // 获得下拉数据 + apiKeyList.value = await ApiKeyApi.getApiKeySimpleList() +}) +</script> diff --git a/src/views/ai/model/chatRole/ChatRoleForm.vue b/src/views/ai/model/chatRole/ChatRoleForm.vue new file mode 100644 index 00000000..a3fbb891 --- /dev/null +++ b/src/views/ai/model/chatRole/ChatRoleForm.vue @@ -0,0 +1,205 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="100px" + v-loading="formLoading" + > + <el-form-item label="角色名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入角色名称"/> + </el-form-item> + <el-form-item label="角色头像" prop="avatar"> + <UploadImg v-model="formData.avatar" height="60px" width="60px"/> + </el-form-item> + <el-form-item label="绑定模型" prop="modelId" v-if="!isUser(formType)"> + <el-select v-model="formData.modelId" placeholder="请选择模型" clearable> + <el-option + v-for="chatModel in chatModelList" + :key="chatModel.id" + :label="chatModel.name" + :value="chatModel.id" + /> + </el-select> + </el-form-item> + <el-form-item label="角色类别" prop="category" v-if="!isUser(formType)"> + <el-input v-model="formData.category" placeholder="请输入角色类别"/> + </el-form-item> + <el-form-item label="角色描述" prop="description"> + <el-input type="textarea" v-model="formData.description" placeholder="请输入角色描述"/> + </el-form-item> + <el-form-item label="角色设定" prop="systemMessage"> + <el-input type="textarea" v-model="formData.systemMessage" placeholder="请输入角色设定" /> + </el-form-item> + <el-form-item label="欢迎语👏🏻" prop="welcomeMessage" v-if="isUser(formType)"> + <el-input type="textarea" v-model="formData.welcomeMessage" placeholder="请输入欢迎语"/> + </el-form-item> + <el-form-item label="是否公开" prop="publicStatus" v-if="!isUser(formType)"> + <el-radio-group v-model="formData.publicStatus"> + <el-radio + v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="角色排序" prop="sort" v-if="!isUser(formType)"> + <el-input-number v-model="formData.sort" placeholder="请输入角色排序" class="!w-1/1"/> + </el-form-item> + <el-form-item label="开启状态" prop="status" v-if="!isUser(formType)"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </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> + </Dialog> +</template> +<script setup lang="ts"> +import {getIntDictOptions, getBoolDictOptions, DICT_TYPE} from '@/utils/dict' +import {ChatRoleApi, ChatRoleVO} from '@/api/ai/model/chatRole' +import {CommonStatusEnum} from '@/utils/constants' +import {ChatModelApi, ChatModelVO} from '@/api/ai/model/chatModel' + +/** AI 聊天角色 表单 */ +defineOptions({name: 'ChatRoleForm'}) + +const {t} = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + modelId: undefined, + name: undefined, + avatar: undefined, + category: undefined, + sort: undefined, + description: undefined, + systemMessage: undefined, + welcomeMessage: undefined, + publicStatus: true, + status: CommonStatusEnum.ENABLE +}) + +// 是否 +const isUser = (type: string) => { + return (type === 'my-create' || type === 'my-update') +} + +const formRules = ref() // reactive(formRulesObj) +const formRef = ref() // 表单 Ref +const chatModelList = ref([] as ChatModelVO[]) // 聊天模型列表 + +const getFormRules = async (type: string) => { + let formRulesObj = { + name: [{required: true, message: '角色名称不能为空', trigger: 'blur'}], + avatar: [{required: true, message: '角色头像不能为空', trigger: 'blur'}], + category: [{required: true, message: '角色类别不能为空', trigger: 'blur'}], + sort: [{required: true, message: '角色排序不能为空', trigger: 'blur'}], + description: [{required: true, message: '角色描述不能为空', trigger: 'blur'}], + systemMessage: [{required: true, message: '角色设定不能为空', trigger: 'blur'}], + // welcomeMessage: [{ required: true, message: '欢迎语不能为空', trigger: 'blur' }], + publicStatus: [{required: true, message: '是否公开不能为空', trigger: 'blur'}] + } + + if (isUser(type)) { + formRulesObj['welcomeMessage'] = [{ + required: true, + message: '欢迎语不能为空', + trigger: 'blur' + }] + } + + formRules.value = reactive(formRulesObj) +} + +/** 打开弹窗 */ +const open = async (type: string, id?: number, title?: string) => { + dialogVisible.value = true + dialogTitle.value = title || t('action.' + type) + formType.value = type + getFormRules(type) + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ChatRoleApi.getChatRole(id) + } finally { + formLoading.value = false + } + } + // 获得下拉数据 + chatModelList.value = await ChatModelApi.getChatModelSimpleList(CommonStatusEnum.ENABLE) +} +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 ChatRoleVO + + // tip: my-create、my-update 是 chat 角色仓库调用 + // tip: create、else 是后台管理调用 + + if (formType.value === 'my-create') { + await ChatRoleApi.createMy(data) + message.success(t('common.createSuccess')) + } else if (formType.value === 'my-update') { + await ChatRoleApi.updateMy(data) + message.success(t('common.updateSuccess')) + } else if (formType.value === 'create') { + await ChatRoleApi.createChatRole(data) + message.success(t('common.createSuccess')) + } else { + await ChatRoleApi.updateChatRole(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + modelId: undefined, + name: undefined, + avatar: undefined, + category: undefined, + sort: undefined, + description: undefined, + systemMessage: undefined, + welcomeMessage: undefined, + publicStatus: true, + status: CommonStatusEnum.ENABLE + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/ai/model/chatRole/index.vue b/src/views/ai/model/chatRole/index.vue new file mode 100644 index 00000000..e870a556 --- /dev/null +++ b/src/views/ai/model/chatRole/index.vue @@ -0,0 +1,187 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="角色名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入角色名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="角色类别" prop="category"> + <el-input + v-model="queryParams.category" + placeholder="请输入角色类别" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="是否公开" prop="publicStatus"> + <el-select + v-model="queryParams.publicStatus" + placeholder="请选择是否公开" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['ai:chat-role:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="角色名称" align="center" prop="name" /> + <el-table-column label="绑定模型" align="center" prop="modelName" /> + <el-table-column label="角色头像" align="center" prop="avatar"> + <template #default="scope"> + <el-image :src="scope?.row.avatar" class="w-32px h-32px" /> + </template> + </el-table-column> + <el-table-column label="角色类别" align="center" prop="category" /> + <el-table-column label="角色描述" align="center" prop="description" /> + <el-table-column label="角色设定" align="center" prop="systemMessage" /> + <el-table-column label="是否公开" align="center" prop="publicStatus"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.publicStatus" /> + </template> + </el-table-column> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column label="角色排序" align="center" prop="sort" /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['ai:chat-role:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['ai:chat-role:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ChatRoleForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { getBoolDictOptions, DICT_TYPE } from '@/utils/dict' +import { ChatRoleApi, ChatRoleVO } from '@/api/ai/model/chatRole' +import ChatRoleForm from './ChatRoleForm.vue' + +/** AI 聊天角色 列表 */ +defineOptions({ name: 'AiChatRole' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const list = ref<ChatRoleVO[]>([]) // 列表的数据 +const total = ref(0) // 列表的总页数 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: undefined, + category: undefined, + publicStatus: true +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ChatRoleApi.getChatRolePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ChatRoleApi.deleteChatRole(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/bpm/oa/leave/index.vue b/src/views/bpm/oa/leave/index.vue index bd41104a..2cb53247 100644 --- a/src/views/bpm/oa/leave/index.vue +++ b/src/views/bpm/oa/leave/index.vue @@ -83,7 +83,7 @@ <el-table-column align="center" label="申请编号" prop="id" /> <el-table-column align="center" label="状态" prop="result"> <template #default="scope"> - <dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" :value="scope.row.result" /> + <dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" :value="scope.row.status" /> </template> </el-table-column> <el-table-column diff --git a/src/views/crm/statistics/performance/components/ContractCountPerformance.vue b/src/views/crm/statistics/performance/components/ContractCountPerformance.vue index f911bb2d..fa5a897b 100644 --- a/src/views/crm/statistics/performance/components/ContractCountPerformance.vue +++ b/src/views/crm/statistics/performance/components/ContractCountPerformance.vue @@ -1,5 +1,4 @@ <!-- 员工业绩统计 --> -<!-- TODO @scholar:参考 ReceivablePricePerformance 建议改 --> <template> <!-- Echarts图 --> <el-card shadow="never"> @@ -64,13 +63,13 @@ const echartsOption = reactive<EChartsOption>({ data: [] }, { - name: '同比增长率(%)', + name: '环比增长率(%)', type: 'line', yAxisIndex: 1, data: [] }, { - name: '环比增长率(%)', + name: '同比增长率(%)', type: 'line', yAxisIndex: 1, data: [] @@ -173,7 +172,9 @@ const loadData = async () => { (s: StatisticsPerformanceRespVO) => s.lastMonthCount ) echartsOption.series[3]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) => - s.lastMonthCount !== 0 ? ((s.currentMonthCount / s.lastMonthCount) * 100).toFixed(2) : 'NULL' + s.lastMonthCount !== 0 + ? (((s.currentMonthCount - s.lastMonthCount) / s.lastMonthCount) * 100).toFixed(2) + : 'NULL' ) } if (echartsOption.series && echartsOption.series[2] && echartsOption.series[2]['data']) { @@ -181,7 +182,9 @@ const loadData = async () => { (s: StatisticsPerformanceRespVO) => s.lastYearCount ) echartsOption.series[4]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) => - s.lastYearCount !== 0 ? ((s.currentMonthCount / s.lastYearCount) * 100).toFixed(2) : 'NULL' + s.lastYearCount !== 0 + ? (((s.currentMonthCount - s.lastYearCount) / s.lastYearCount) * 100).toFixed(2) + : 'NULL' ) } @@ -197,8 +200,8 @@ const tableData = reactive([ { title: '当月合同数量统计(个)' }, { title: '上月合同数量统计(个)' }, { title: '去年当月合同数量统计(个)' }, - { title: '同比增长率(%)' }, - { title: '环比增长率(%)' } + { title: '环比增长率(%)' }, + { title: '同比增长率(%)' } ]) // 定义 convertListData 方法,数据行列转置,展示每月数据 @@ -214,9 +217,13 @@ const convertListData = () => { tableData[1]['prop' + index] = item.lastMonthCount tableData[2]['prop' + index] = item.lastYearCount tableData[3]['prop' + index] = - item.lastMonthCount !== 0 ? (item.currentMonthCount / item.lastMonthCount).toFixed(2) : 'NULL' + item.lastMonthCount !== 0 + ? (((item.currentMonthCount - item.lastMonthCount) / item.lastMonthCount) * 100).toFixed(2) + : 'NULL' tableData[4]['prop' + index] = - item.lastYearCount !== 0 ? (item.currentMonthCount / item.lastYearCount).toFixed(2) : 'NULL' + item.lastYearCount !== 0 + ? (((item.currentMonthCount - item.lastYearCount) / item.lastYearCount) * 100).toFixed(2) + : 'NULL' }) } diff --git a/src/views/crm/statistics/performance/components/ContractPricePerformance.vue b/src/views/crm/statistics/performance/components/ContractPricePerformance.vue index f97b612c..dd52d9fb 100644 --- a/src/views/crm/statistics/performance/components/ContractPricePerformance.vue +++ b/src/views/crm/statistics/performance/components/ContractPricePerformance.vue @@ -1,5 +1,4 @@ <!-- 员工业绩统计 --> -<!-- TODO @scholar:参考 ReceivablePricePerformance 建议改 --> <template> <!-- Echarts图 --> <el-card shadow="never"> @@ -64,13 +63,13 @@ const echartsOption = reactive<EChartsOption>({ data: [] }, { - name: '同比增长率(%)', + name: '环比增长率(%)', type: 'line', yAxisIndex: 1, data: [] }, { - name: '环比增长率(%)', + name: '同比增长率(%)', type: 'line', yAxisIndex: 1, data: [] @@ -173,7 +172,9 @@ const loadData = async () => { (s: StatisticsPerformanceRespVO) => s.lastMonthCount ) echartsOption.series[3]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) => - s.lastMonthCount !== 0 ? ((s.currentMonthCount / s.lastMonthCount) * 100).toFixed(2) : 'NULL' + s.lastMonthCount !== 0 + ? (((s.currentMonthCount - s.lastMonthCount) / s.lastMonthCount) * 100).toFixed(2) + : 'NULL' ) } if (echartsOption.series && echartsOption.series[2] && echartsOption.series[2]['data']) { @@ -181,7 +182,9 @@ const loadData = async () => { (s: StatisticsPerformanceRespVO) => s.lastYearCount ) echartsOption.series[4]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) => - s.lastYearCount !== 0 ? ((s.currentMonthCount / s.lastYearCount) * 100).toFixed(2) : 'NULL' + s.lastYearCount !== 0 + ? (((s.currentMonthCount - s.lastYearCount) / s.lastYearCount) * 100).toFixed(2) + : 'NULL' ) } @@ -197,8 +200,8 @@ const tableData = reactive([ { title: '当月合同金额统计(元)' }, { title: '上月合同金额统计(元)' }, { title: '去年当月合同金额统计(元)' }, - { title: '同比增长率(%)' }, - { title: '环比增长率(%)' } + { title: '环比增长率(%)' }, + { title: '同比增长率(%)' } ]) // 定义 init 方法 @@ -214,9 +217,13 @@ const convertListData = () => { tableData[1]['prop' + index] = item.lastMonthCount tableData[2]['prop' + index] = item.lastYearCount tableData[3]['prop' + index] = - item.lastMonthCount !== 0 ? (item.currentMonthCount / item.lastMonthCount).toFixed(2) : 'NULL' + item.lastMonthCount !== 0 + ? (((item.currentMonthCount - item.lastMonthCount) / item.lastMonthCount) * 100).toFixed(2) + : 'NULL' tableData[4]['prop' + index] = - item.lastYearCount !== 0 ? (item.currentMonthCount / item.lastYearCount).toFixed(2) : 'NULL' + item.lastYearCount !== 0 + ? (((item.currentMonthCount - item.lastYearCount) / item.lastYearCount) * 100).toFixed(2) + : 'NULL' }) } diff --git a/src/views/crm/statistics/performance/components/ReceivablePricePerformance.vue b/src/views/crm/statistics/performance/components/ReceivablePricePerformance.vue index 14f59909..169f074b 100644 --- a/src/views/crm/statistics/performance/components/ReceivablePricePerformance.vue +++ b/src/views/crm/statistics/performance/components/ReceivablePricePerformance.vue @@ -17,7 +17,6 @@ :prop="item.prop" align="center" > - <!-- TODO @scholar:IDEA 爆红的处理 --> <template #default="scope"> {{ scope.row[item.prop] }} </template> @@ -64,13 +63,13 @@ const echartsOption = reactive<EChartsOption>({ data: [] }, { - name: '同比增长率(%)', + name: '环比增长率(%)', type: 'line', yAxisIndex: 1, data: [] }, { - name: '环比增长率(%)', + name: '同比增长率(%)', type: 'line', yAxisIndex: 1, data: [] @@ -121,7 +120,6 @@ const echartsOption = reactive<EChartsOption>({ type: 'value', name: '', axisTick: { - // TODO @scholar:IDEA 爆红的处理 alignWithLabel: true, lineStyle: { width: 0 @@ -174,7 +172,9 @@ const loadData = async () => { (s: StatisticsPerformanceRespVO) => s.lastMonthCount ) echartsOption.series[3]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) => - s.lastMonthCount !== 0 ? ((s.currentMonthCount / s.lastMonthCount) * 100).toFixed(2) : 'NULL' + s.lastMonthCount !== 0 + ? (((s.currentMonthCount - s.lastMonthCount) / s.lastMonthCount) * 100).toFixed(2) + : 'NULL' ) } if (echartsOption.series && echartsOption.series[2] && echartsOption.series[1]['data']) { @@ -182,7 +182,9 @@ const loadData = async () => { (s: StatisticsPerformanceRespVO) => s.lastYearCount ) echartsOption.series[4]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) => - s.lastYearCount !== 0 ? ((s.currentMonthCount / s.lastYearCount) * 100).toFixed(2) : 'NULL' + s.lastYearCount !== 0 + ? (((s.currentMonthCount - s.lastYearCount) / s.lastYearCount) * 100).toFixed(2) + : 'NULL' ) } @@ -193,14 +195,13 @@ const loadData = async () => { } // 初始化数据 -// TODO @scholar:加个 as any[],避免 idea 爆红 -const columnsData = reactive([] as any[]) +const columnsData = reactive([]) const tableData = reactive([ { title: '当月回款金额统计(元)' }, { title: '上月回款金额统计(元)' }, { title: '去年当月回款金额统计(元)' }, - { title: '同比增长率(%)' }, - { title: '环比增长率(%)' } + { title: '环比增长率(%)' }, + { title: '同比增长率(%)' } ]) // 定义 init 方法 @@ -215,11 +216,14 @@ const convertListData = () => { tableData[0]['prop' + index] = item.currentMonthCount tableData[1]['prop' + index] = item.lastMonthCount tableData[2]['prop' + index] = item.lastYearCount - // TODO @scholar:百分比,使用 erpCalculatePercentage 直接计算;如果是 0,则返回 0,统一就好哈; tableData[3]['prop' + index] = - item.lastMonthCount !== 0 ? (item.currentMonthCount / item.lastMonthCount).toFixed(2) : 'NULL' + item.lastMonthCount !== 0 + ? (((item.currentMonthCount - item.lastMonthCount) / item.lastMonthCount) * 100).toFixed(2) + : 'NULL' tableData[4]['prop' + index] = - item.lastYearCount !== 0 ? (item.currentMonthCount / item.lastYearCount).toFixed(2) : 'NULL' + item.lastYearCount !== 0 + ? (((item.currentMonthCount - item.lastYearCount) / item.lastYearCount) * 100).toFixed(2) + : 'NULL' }) } diff --git a/src/views/crm/statistics/performance/index.vue b/src/views/crm/statistics/performance/index.vue index 4a443c55..822afec9 100644 --- a/src/views/crm/statistics/performance/index.vue +++ b/src/views/crm/statistics/performance/index.vue @@ -73,7 +73,7 @@ import * as DeptApi from '@/api/system/dept' import * as UserApi from '@/api/system/user' import { useUserStore } from '@/store/modules/user' -import { beginOfDay, formatDate } from '@/utils/formatTime' +import { beginOfDay, endOfDay, formatDate } from '@/utils/formatTime' import { defaultProps, handleTree } from '@/utils/tree' import ContractCountPerformance from './components/ContractCountPerformance.vue' import ContractPricePerformance from './components/ContractPricePerformance.vue' @@ -85,8 +85,8 @@ const queryParams = reactive({ deptId: useUserStore().getUser.deptId, userId: undefined, times: [ - // 默认显示当年的数据 - formatDate(beginOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24 * 7))) + formatDate(beginOfDay(new Date(new Date().getFullYear(), 0, 1))), + formatDate(endOfDay(new Date(new Date().getFullYear(), 11, 31))) ] }) @@ -100,31 +100,20 @@ const userListByDeptId = computed(() => : [] ) -// TODO @scholar:改成尾注释,保证 vue 内容短一点;变量名小写 // 活跃标签 const activeTab = ref('ContractCountPerformance') -// 1.员工合同数量统计 -const ContractCountPerformanceRef = ref() -// 2.员工合同金额统计 -const ContractPricePerformanceRef = ref() -// 3.员工回款金额统计 -const ReceivablePricePerformanceRef = ref() +const ContractCountPerformanceRef = ref() // 员工合同数量统计 +const ContractPricePerformanceRef = ref() // 员工合同金额统计 +const ReceivablePricePerformanceRef = ref() // 员工回款金额统计 /** 搜索按钮操作 */ const handleQuery = () => { // 从 queryParams.times[0] 中获取到了年份 const selectYear = parseInt(queryParams.times[0]) + queryParams.times[0] = formatDate(beginOfDay(new Date(selectYear, 0, 1))) + queryParams.times[1] = formatDate(endOfDay(new Date(selectYear, 11, 31))) - // 创建一个新的 Date 对象,设置为指定的年份的第一天 - const fullDate = new Date(selectYear, 0, 1, 0, 0, 0) - - // 将完整的日期时间格式化为需要的字符串形式,比如 2004-01-01 00:00:00 - // TODO @scholar:看看,是不是可以使用 year 哈 - queryParams.times[0] = `${fullDate.getFullYear()}-${String(fullDate.getMonth() + 1).padStart( - 2, - '0' - )}-${String(fullDate.getDate()).padStart(2, '0')} ${String(fullDate.getHours()).padStart(2, '0')}:${String(fullDate.getMinutes()).padStart(2, '0')}:${String(fullDate.getSeconds()).padStart(2, '0')}` - + // 执行查询 switch (activeTab.value) { case 'ContractCountPerformance': ContractCountPerformanceRef.value?.loadData?.() diff --git a/src/views/system/dept/index.vue b/src/views/system/dept/index.vue index e99d7f8a..4757e5c0 100644 --- a/src/views/system/dept/index.vue +++ b/src/views/system/dept/index.vue @@ -8,7 +8,7 @@ :inline="true" label-width="68px" > - <el-form-item label="部门名称" prop="title"> + <el-form-item label="部门名称" prop="name"> <el-input v-model="queryParams.name" placeholder="请输入部门名称"