This commit is contained in:
YunaiV 2024-06-23 11:29:23 +08:00
commit 64a691a3c7
58 changed files with 6519 additions and 281 deletions

View File

@ -6,7 +6,7 @@
"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",
@ -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",
@ -46,11 +47,12 @@
"driver.js": "^1.3.1",
"echarts": "^5.5.0",
"echarts-wordcloud": "^2.1.0",
"element-plus": "2.6.1",
"element-plus": "2.7.0",
"fast-xml-parser": "^4.3.2",
"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",

View File

@ -17,6 +17,9 @@ dependencies:
'@iconify/iconify':
specifier: ^3.1.1
version: 3.1.1
'@microsoft/fetch-event-source':
specifier: ^2.0.1
version: 2.0.1
'@videojs-player/vue':
specifier: ^1.0.0
version: 1.0.0(@types/video.js@7.3.58)(video.js@7.21.5)(vue@3.4.21)
@ -69,8 +72,8 @@ dependencies:
specifier: ^2.1.0
version: 2.1.0(echarts@5.5.0)
element-plus:
specifier: 2.6.1
version: 2.6.1(vue@3.4.21)
specifier: 2.7.0
version: 2.7.0(vue@3.4.21)
fast-xml-parser:
specifier: ^4.3.2
version: 4.3.6
@ -83,6 +86,9 @@ dependencies:
lodash-es:
specifier: ^4.17.21
version: 4.17.21
marked:
specifier: ^12.0.2
version: 12.0.2
min-dash:
specifier: ^4.1.1
version: 4.2.1
@ -2360,6 +2366,10 @@ packages:
'@jridgewell/sourcemap-codec': 1.4.15
dev: true
/@microsoft/fetch-event-source@2.0.1:
resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==, tarball: https://registry.npmmirror.com/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz}
dev: false
/@nodelib/fs.scandir@2.1.5:
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==, tarball: https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz}
engines: {node: '>= 8'}
@ -5082,8 +5092,8 @@ packages:
resolution: {integrity: sha512-9ItEpeu15hW5m8jKdriL+BQrgwDTXEL9pn4SkillWFu73ZNNNQ2BKKLS+ZHv2vC9UkNhosAeyfxOf/5OSeTCPA==, tarball: https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.4.750.tgz}
dev: true
/element-plus@2.6.1(vue@3.4.21):
resolution: {integrity: sha512-6VRpLjwtIVdtUuITJPPKtpOH1NM6nuAkRE3q5O4Lrx0N1bYMhTkiqb2Jy7zfQuDPbOIkkF2OABTzegpNnzgsnQ==, tarball: https://registry.npmmirror.com/element-plus/-/element-plus-2.6.1.tgz}
/element-plus@2.7.0(vue@3.4.21):
resolution: {integrity: sha512-WAiaFLavuWFxof9qwkC27jvkh9nRcNnB506g1vvJSiVaVqjCBWUFCIyJKeN11M1qcv2cS5VV5PfSLjTIkrw87A==, tarball: https://registry.npmmirror.com/element-plus/-/element-plus-2.7.0.tgz}
peerDependencies:
vue: ^3.2.0
dependencies:
@ -6917,6 +6927,12 @@ packages:
object-visit: 1.0.1
dev: true
/marked@12.0.2:
resolution: {integrity: sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==, tarball: https://registry.npmmirror.com/marked/-/marked-12.0.2.tgz}
engines: {node: '>= 18'}
hasBin: true
dev: false
/matches-selector@1.2.0:
resolution: {integrity: sha512-c4vLwYWyl+Ji+U43eU/G5FwxWd4ZH0ePUsFs5y0uwD9HUEFBXUQ1zUUan+78IpRD+y4pUfG0nAzNM292K7ItvA==, tarball: https://registry.npmmirror.com/matches-selector/-/matches-selector-1.2.0.tgz}
dev: true

View File

@ -54,4 +54,8 @@ body {
.#{$prefix-cls}-grey-mode {
filter: grayscale(100%);
}
.scrollbar__view {
height: 99%!important;
}
</style>

View File

@ -0,0 +1,64 @@
import request from '@/config/axios'
// AI 聊天对话 VO
export interface ChatConversationVO {
id: string // ID 编号
userId: number // 用户编号
title: string // 对话标题
pinned: boolean // 是否置顶
roleId: number // 角色编号
modelId: number // 模型编号
model: string // 模型标志
temperature: number // 温度参数
maxTokens: number // 单条回复的最大 Token 数量
maxContexts: number // 上下文的最大 Message 数量
// 额外字段
systemMessage?: string // 角色设定
modelName?: string // 模型名字
roleAvatar?: string // 角色头像
modelMaxTokens?: string // 模型的单条回复的最大 Token 数量
modelMaxContexts?: string // 模型的上下文的最大 Message 数量
}
// AI 聊天对话 API
export const ChatConversationApi = {
// 获得【我的】聊天对话
getChatConversationMy: async (id: string) => {
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: string) => {
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` })
},
// 获得对话分页
getChatConversationPage: async (params: any) => {
return await request.get({ url: `/ai/chat/conversation/page`, params })
},
// 管理员删除消息
deleteChatConversationByAdmin: async (id: number) => {
return await request.delete({ url: `/ai/chat/conversation/delete-by-admin?id=${id}` })
}
}

View File

@ -0,0 +1,88 @@
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 // 创建时间
roleAvatar: string // 角色头像
userAvatar: string // 创建时间
}
export interface ChatMessageSendVO {
conversationId: string // 对话编号
content: number // 聊天内容
}
// AI chat 聊天
export const ChatMessageApi = {
// 消息列表
messageList: async (conversationId: string | 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,
enableContext: boolean,
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,
useContext: enableContext
}),
onmessage: onMessage,
onerror: onError,
onclose: onClose,
signal: ctrl.signal
})
},
// 删除消息
delete: async (id: string) => {
return await request.delete({ url: `/ai/chat/message/delete?id=${id}` })
},
// 删除消息 - 对话所有消息
deleteByConversationId: async (conversationId: string) => {
return await request.delete({
url: `/ai/chat/message/delete-by-conversation-id?conversationId=${conversationId}`
})
},
// 获得消息分页
getChatMessagePage: async (params: any) => {
return await request.get({ url: '/ai/chat/message/page', params })
},
// 管理员删除消息
deleteChatMessageByAdmin: async (id: number) => {
return await request.delete({ url: `/ai/chat/message/delete-by-admin?id=${id}` })
}
}

98
src/api/ai/image/index.ts Normal file
View File

@ -0,0 +1,98 @@
import request from '@/config/axios'
// AI API 密钥 VO
// TODO @fan要不前端不弄太多 VO就用这个 ImageDetailVO
export interface ImageDetailVO {
id: number // 编号
prompt: string // 提示词
status: number // 状态
errorMessage: string // 错误信息
type: string // 模型下分不同的类型(清晰、真实...)
taskId: number // dr 任务id
picUrl: string // 任务地址
originalPicUrl: string // 绘制图片地址
platform: string // 平台
model: string // 模型
style: string // 图像生成的风格
size: string // 图片尺寸
buttons: ImageMjButtonsVO[] // mj 操作按钮
createTime: string // 创建时间
updateTime: string // 更新事件
}
export interface ImageMjButtonsVO {
customId: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
emoji: string // 图标 emoji
label: string // Make Variations 文本
style: number // 样式: 2Primary、3Green
}
export interface ImageMjActionVO {
id: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
customId: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
}
export interface ImagePageReqVO {
pageNo: number // 分页编号
pageSize: number // 分页大小
}
export interface ImageDallReqVO {
prompt: string // 提示词
model: string // 模型
style: string // 图像生成的风格
width: string // 图片宽度
height: string // 图片高度
}
export interface ImageDrawReqVO {
platform: string // 平台
prompt: string // 提示词
model: string // 模型
style: string // 图像生成的风格
width: string // 图片宽度
height: string // 图片高度
options: object // 绘制参数Map<String, String>
}
export interface ImageMidjourneyImagineReqVO {
prompt: string // 提示词
model: string // 模型 mj nijj
base64Array: string[] // size不能为空
width: string // 图片宽度
height: string // 图片高度
version: string // 版本
}
// TODO 芋艿review 下整体注释、方法名
// AI API 密钥 API
export const ImageApi = {
// 获取 image 列表
getImageList: async (params: ImagePageReqVO) => {
return await request.get({ url: `/ai/image/my-page`, params })
},
// 获取 image 详细信息
getImageDetail: async (id: number) => {
return await request.get({ url: `/ai/image/get-my?id=${id}`})
},
// 生成图片
drawImage: async (data: ImageDrawReqVO)=> {
return await request.post({ url: `/ai/image/draw`, data })
},
// 删除
deleteImage: async (id: number)=> {
return await request.delete({ url: `/ai/image/delete-my?id=${id}`})
},
// ------------ midjourney
// midjourney - imagine
midjourneyImagine: async (data: ImageMidjourneyImagineReqVO)=> {
return await request.post({ url: `/ai/image/midjourney/imagine`, data })
},
// midjourney - action
midjourneyAction: async (params: ImageMjActionVO)=> {
return await request.get({ url: `/ai/image/midjourney/action`, params })
},
}

View File

@ -0,0 +1,44 @@
import request from '@/config/axios'
// AI API 密钥 VO
export interface ApiKeyVO {
id: number // 编号
name: string // 名称
apiKey: string // 密钥
platform: string // 平台
url: string // 自定义 API 地址
status: number // 状态
}
// AI API 密钥 API
export const ApiKeyApi = {
// 查询 API 密钥分页
getApiKeyPage: async (params: any) => {
return await request.get({ url: `/ai/api-key/page`, params })
},
// 获得 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 })
},
// 新增 API 密钥
createApiKey: async (data: ApiKeyVO) => {
return await request.post({ url: `/ai/api-key/create`, data })
},
// 修改 API 密钥
updateApiKey: async (data: ApiKeyVO) => {
return await request.put({ url: `/ai/api-key/update`, data })
},
// 删除 API 密钥
deleteApiKey: async (id: number) => {
return await request.delete({ url: `/ai/api-key/delete?id=` + id })
}
}

View File

@ -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 })
}
}

View File

@ -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 })
},
}

1
src/assets/ai/clear.svg Normal file
View File

@ -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="1716342375293" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2604" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M899.1 869.6l-53-305.6H864c14.4 0 26-11.6 26-26V346c0-14.4-11.6-26-26-26H618V138c0-14.4-11.6-26-26-26H432c-14.4 0-26 11.6-26 26v182H160c-14.4 0-26 11.6-26 26v192c0 14.4 11.6 26 26 26h17.9l-53 305.6c-0.3 1.5-0.4 3-0.4 4.4 0 14.4 11.6 26 26 26h723c1.5 0 3-0.1 4.4-0.4 14.2-2.4 23.7-15.9 21.2-30zM204 390h272V182h72v208h272v104H204V390z m468 440V674c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8v156H416V674c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8v156H202.8l45.1-260H776l45.1 260H672z" p-id="2605" fill="#8a8a8a"></path></svg>

After

Width:  |  Height:  |  Size: 844 B

View File

@ -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>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
src/assets/ai/copy.svg Normal file
View File

@ -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>

After

Width:  |  Height:  |  Size: 1.2 KiB

1
src/assets/ai/delete.svg Normal file
View File

@ -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>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
src/assets/ai/gpt.svg Normal file
View File

@ -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="1716345268026" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5622" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M956.408445 419.226665a250.670939 250.670939 0 0 0-22.425219-209.609236A263.163526 263.163526 0 0 0 652.490412 85.715535 259.784384 259.784384 0 0 0 457.728923 0.008192a261.422756 261.422756 0 0 0-249.44216 178.582564 258.453206 258.453206 0 0 0-172.848261 123.901894c-57.03583 96.868753-44.031251 219.132275 32.153053 302.279661a250.670939 250.670939 0 0 0 22.32282 209.609237 263.163526 263.163526 0 0 0 281.595213 123.901893A259.067596 259.067596 0 0 0 566.271077 1023.990784a260.60357 260.60357 0 0 0 249.339762-178.889759 258.453206 258.453206 0 0 0 172.848261-123.901893c57.445423-96.868753 44.13365-218.82508-32.050655-302.074865zM566.578272 957.124721c-45.362429 0-89.496079-15.666934-124.516283-44.543243 1.638372-0.921584 4.198329-2.150363 6.143895-3.481541l206.537289-117.757998a32.35785 32.35785 0 0 0 16.895713-29.081105V474.82892l87.243317 49.97035c1.023983 0.307195 1.638372 1.228779 1.638372 2.252762v238.075953c0 105.8798-86.936122 191.689541-193.942303 191.996736zM148.588578 781.102113a189.846373 189.846373 0 0 1-23.346803-128.612213c1.535974 1.023983 4.09593 2.559956 6.143895 3.48154L337.922959 773.729439c10.444622 6.143896 23.346803 6.143896 34.098621 0l252.30931-143.664758v99.531108c0 1.023983-0.307195 1.945567-1.331177 2.559956l-208.892449 118.986778a196.297463 196.297463 0 0 1-265.518686-70.04041zM94.112704 335.97688c22.630015-39.013737 58.367008-68.81163 101.16948-84.171369V494.591784c0 11.7758 6.45109 22.93721 16.793315 28.978707l252.30931 143.767156L377.141493 716.796006a3.174346 3.174346 0 0 1-2.867152 0.307195l-208.892448-118.986777A190.870355 190.870355 0 0 1 94.215102 335.874482z m717.607001 164.861198L559.410394 357.070922 646.653711 307.20297a3.174346 3.174346 0 0 1 2.969549-0.307195l208.892449 118.986777a190.358364 190.358364 0 0 1 70.961994 262.139544 194.556693 194.556693 0 0 1-101.16948 84.171369V529.407192a31.538664 31.538664 0 0 0-16.588518-28.671513z m87.03852-129.329002c-1.74077-1.023983-4.300727-2.559956-6.246294-3.48154l-206.639687-117.757999a34.09862 34.09862 0 0 0-33.996222 0L399.566711 393.934295v-99.531108c0-1.023983 0.307195-1.945567 1.331178-2.559956l208.892449-119.089176a195.990268 195.990268 0 0 1 265.518686 70.450003c22.732414 38.706542 31.129071 84.171369 23.346803 128.305018zM352.258716 548.862861l-87.243317-49.560757a2.457558 2.457558 0 0 1-1.638372-2.252762V258.870991c0-105.8798 87.243317-191.996736 194.556692-191.689541a194.556693 194.556693 0 0 1 124.209089 44.543243c-1.638372 0.921584-4.198329 2.252762-6.143896 3.48154l-206.639687 117.757999a31.948257 31.948257 0 0 0-16.793315 29.081105l-0.307194 286.715126z m47.307995-100.759887L512 384.001664l112.535687 63.998912v127.997824l-112.228492 63.998912-112.535687-63.998912-0.307195-127.997824z" p-id="5623" fill="#707070"></path></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -27,6 +27,11 @@ export const useApiSelect = (option: ApiSelectProps) => {
type: String,
default: 'GET'
},
// 选项解析函数
parseFunc: {
type: String,
default: ''
},
// 请求参数
data: {
type: String,
@ -41,35 +46,121 @@ export const useApiSelect = (option: ApiSelectProps) => {
multiple: {
type: Boolean,
default: false
},
// 是否远程搜索
remote: {
type: Boolean,
default: false
},
// 远程搜索时携带的参数
remoteField: {
type: String,
default: 'label'
}
},
setup(props) {
const attrs = useAttrs()
const options = ref<any[]>([]) // 下拉数据
const loading = ref(false) // 是否正在从远程获取数据
const queryParam = ref<any>() // 当前输入的值
const getOptions = async () => {
options.value = []
// 接口选择器
if (isEmpty(props.url)) {
return
}
let data = []
switch (props.method) {
case 'GET':
data = await request.get({ url: props.url })
let url: string = props.url
if (props.remote) {
url = `${url}?${props.remoteField}=${queryParam.value}`
}
parseOptions(await request.get({ url: url }))
break
case 'POST':
data = await request.post({ url: props.url, data: jsonParse(props.data) })
const data: any = jsonParse(props.data)
if (props.remote) {
data[props.remoteField] = queryParam.value
}
parseOptions(await request.post({ url: props.url, data: data }))
break
}
}
function parseOptions(data: any) {
// 情况一:如果有自定义解析函数优先使用自定义解析
if (!isEmpty(props.parseFunc)) {
options.value = parseFunc()?.(data)
return
}
// 情况二:返回的直接是一个列表
if (Array.isArray(data)) {
parseOptions0(data)
return
}
// 情况二:返回的是分页数据,尝试读取 list
data = data.list
if (!!data && Array.isArray(data)) {
parseOptions0(data)
return
}
// 情况三:不是 yudao-vue-pro 标准返回
console.warn(
`接口[${props.url}] 返回结果不是 yudao-vue-pro 标准返回建议采用自定义解析函数处理`
)
}
function parseOptions0(data: any[]) {
if (Array.isArray(data)) {
options.value = data.map((item: any) => ({
label: item[props.labelField],
value: item[props.valueField]
label: parseExpression(item, props.labelField),
value: parseExpression(item, props.valueField)
}))
return
}
console.error(`接口[${props.url}] 返回结果不是一个数组`)
console.warn(`接口[${props.url}] 返回结果不是一个数组`)
}
function parseFunc() {
let parse: any = null
if (!!props.parseFunc) {
// 解析字符串函数
parse = new Function(`return ${props.parseFunc}`)()
}
return parse
}
function parseExpression(data: any, template: string) {
// 检测是否使用了表达式
if (template.indexOf('${') === -1) {
return data[template]
}
// 正则表达式匹配模板字符串中的 ${...}
const pattern = /\$\{([^}]*)}/g
// 使用replace函数配合正则表达式和回调函数来进行替换
return template.replace(pattern, (_, expr) => {
// expr 是匹配到的 ${} 内的表达式(这里是属性名),从 data 中获取对应的值
const result = data[expr.trim()] // 去除前后空白,以防用户输入带空格的属性名
if (!result) {
console.warn(
`接口选择器选项模版[${template}][${expr.trim()}] 解析值失败结果为[${result}], 请检查属性名称是否存在于接口返回值中,存在则忽略此条!!!`
)
}
return result
})
}
const remoteMethod = async (query: any) => {
if (!query) {
return
}
loading.value = true
try {
queryParam.value = query
await getOptions()
} finally {
loading.value = false
}
}
onMounted(async () => {
@ -80,15 +171,29 @@ export const useApiSelect = (option: ApiSelectProps) => {
if (props.multiple) {
// fix多写此步是为了解决 multiple 属性问题
return (
<el-select class="w-1/1" {...attrs} multiple>
<el-select
class="w-1/1"
multiple
loading={loading.value}
{...attrs}
remote={props.remote}
{...(props.remote && { remoteMethod: remoteMethod })}
>
{options.value.map((item, index) => (
<el-option key={index} label={item.label} value={item.value} />
))}
</el-select>
)
}
debugger
return (
<el-select class="w-1/1" {...attrs}>
<el-select
class="w-1/1"
loading={loading.value}
{...attrs}
remote={props.remote}
{...(props.remote && { remoteMethod: remoteMethod })}
>
{options.value.map((item, index) => (
<el-option key={index} label={item.label} value={item.value} />
))}

View File

@ -13,12 +13,30 @@ const selectRule = [
control: [
{
value: 'select',
condition: '=',
condition: '==',
method: 'hidden',
rule: ['multiple']
rule: [
'multiple',
'clearable',
'collapseTags',
'multipleLimit',
'allowCreate',
'filterable',
'noMatchText',
'remote',
'remoteMethod',
'reserveKeyword',
'defaultFirstOption',
'automaticDropdown'
]
}
]
},
{
type: 'switch',
field: 'filterable',
title: '是否可搜索'
},
{ type: 'switch', field: 'multiple', title: '是否多选' },
{
type: 'switch',
@ -43,27 +61,12 @@ const selectRule = [
title: 'autocomplete 属性'
},
{ type: 'input', field: 'placeholder', title: '占位符' },
{
type: 'switch',
field: 'filterable',
title: '是否可搜索'
},
{ type: 'switch', field: 'allowCreate', title: '是否允许用户创建新条目' },
{
type: 'input',
field: 'noMatchText',
title: '搜索条件无匹配时显示的文字'
},
{
type: 'switch',
field: 'remote',
title: '其中的选项是否从服务器远程加载'
},
{
type: 'Struct',
field: 'remoteMethod',
title: '自定义远程搜索方法'
},
{ type: 'input', field: 'noDataText', title: '选项为空时显示的文字' },
{
type: 'switch',
@ -130,6 +133,7 @@ const apiSelectRule = [
type: 'input',
field: 'labelField',
title: 'label 属性',
info: '可以使用 el 表达式:${属性},来实现复杂数据组合。如:${nickname}-${id}',
props: {
placeholder: 'nickname'
}
@ -138,9 +142,39 @@ const apiSelectRule = [
type: 'input',
field: 'valueField',
title: 'value 属性',
info: '可以使用 el 表达式:${属性},来实现复杂数据组合。如:${nickname}-${id}',
props: {
placeholder: 'id'
}
},
{
type: 'input',
field: 'parseFunc',
title: '选项解析函数',
info: `data 为接口返回值,需要写一个匿名函数解析返回值为选择器 options 列表
(data: any)=>{ label: string; value: any }[]`,
props: {
autosize: true,
rows: { minRows: 2, maxRows: 6 },
type: 'textarea',
placeholder: `
function (data) {
console.log(data)
return data.list.map(item=> ({label: item.nickname,value: item.id}))
}`
}
},
{
type: 'switch',
field: 'remote',
info: '是否可搜索',
title: '其中的选项是否从服务器远程加载'
},
{
type: 'input',
field: 'remoteField',
title: '请求参数',
info: '远程请求时请求携带的参数名称name'
}
]

View File

@ -2,6 +2,7 @@ import { generateUUID } from '@/utils'
import * as DictDataApi from '@/api/system/dict/dict.type'
import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
import { selectRule } from '@/components/FormCreate/src/config/selectRule'
import { cloneDeep } from 'lodash-es'
/**
* 使使 useSelectRule
@ -9,6 +10,7 @@ import { selectRule } from '@/components/FormCreate/src/config/selectRule'
export const useDictSelectRule = () => {
const label = '字典选择器'
const name = 'DictSelect'
const rules = cloneDeep(selectRule)
const dictOptions = ref<{ label: string; value: string }[]>([]) // 字典类型下拉数据
onMounted(async () => {
const data = await DictDataApi.getSimpleDictTypeList()
@ -55,7 +57,7 @@ export const useDictSelectRule = () => {
{ label: '布尔值', value: 'bool' }
]
},
...selectRule
...rules
])
}
}

View File

@ -2,6 +2,7 @@ import { generateUUID } from '@/utils'
import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
import { selectRule } from '@/components/FormCreate/src/config/selectRule'
import { SelectRuleOption } from '@/components/FormCreate/src/type'
import { cloneDeep } from 'lodash-es'
/**
* hook
@ -11,6 +12,7 @@ import { SelectRuleOption } from '@/components/FormCreate/src/type'
export const useSelectRule = (option: SelectRuleOption) => {
const label = option.label
const name = option.name
const rules = cloneDeep(selectRule)
return {
icon: option.icon,
label,
@ -28,7 +30,7 @@ export const useSelectRule = (option: SelectRuleOption) => {
if (!option.props) {
option.props = []
}
return localeProps(t, name + '.props', [makeRequiredRule(), ...option.props, ...selectRule])
return localeProps(t, name + '.props', [makeRequiredRule(), ...option.props, ...rules])
}
}
}

View File

@ -71,9 +71,9 @@ export const useFormCreateDesigner = async (designer: Ref) => {
*/
const buildSystemMenu = () => {
// 移除自带的下拉选择器组件,使用 currencySelectRule 替代
designer.value?.removeMenuItem('select')
designer.value?.removeMenuItem('radio')
designer.value?.removeMenuItem('checkbox')
// designer.value?.removeMenuItem('select')
// designer.value?.removeMenuItem('radio')
// designer.value?.removeMenuItem('checkbox')
const components = [userSelectRule, deptSelectRule, dictSelectRule, apiSelectRule0]
const menu: Menu = {
name: 'system',

View File

@ -1,4 +1,3 @@
// TODO puhui999: 借鉴一下 form-create-designer utils 方法 🤣 (导入不了只能先 copy 过来用下)
export function makeRequiredRule() {
return {
type: 'Required',
@ -17,63 +16,3 @@ export const localeProps = (t, prefix, rules) => {
return rule
})
}
export function upper(str) {
return str.replace(str[0], str[0].toLocaleUpperCase())
}
export function makeOptionsRule(t, to, userOptions) {
console.log(userOptions[0])
const options = [
{ label: t('props.optionsType.struct'), value: 0 },
{ label: t('props.optionsType.json'), value: 1 },
{ label: '用户数据', value: 2 }
]
const control = [
{
value: 0,
rule: [
{
type: 'TableOptions',
field: 'formCreate' + upper(to).replace('.', '>'),
props: { defaultValue: [] }
}
]
},
{
value: 1,
rule: [
{
type: 'Struct',
field: 'formCreate' + upper(to).replace('.', '>'),
props: { defaultValue: [] }
}
]
},
{
value: 2,
rule: [
{
type: 'TableOptions',
field: 'formCreate' + upper(to).replace('.', '>'),
props: { modelValue: [] }
}
]
}
]
options.splice(0, 0)
control.push()
return {
type: 'radio',
title: t('props.options'),
field: '_optionType',
value: 0,
options,
props: {
type: 'button'
},
control
}
}

View File

@ -22,7 +22,7 @@ const props = defineProps({
const elRef = ref<ElRef>(null)
const isLocal = computed(() => props.icon.startsWith('svg-icon:'))
const isLocal = computed(() => props.icon?.startsWith('svg-icon:'))
const symbolId = computed(() => {
return unref(isLocal) ? `#icon-${props.icon.split('svg-icon:')[1]}` : props.icon

View File

@ -0,0 +1,235 @@
<template>
<div ref="contentRef" class="markdown-view" v-html="contentHtml"></div>
</template>
<script setup lang="ts">
import {useClipboard} from "@vueuse/core";
import {marked} from 'marked'
import 'highlight.js/styles/vs2015.min.css'
import hljs from 'highlight.js'
import {ref} from "vue";
const {copy} = useClipboard() // copy
const contentRef = ref()
// https://highlightjs.org/
// markdownmarked
// marked
const renderer = {
code(code, language, c) {
let highlightHtml
try {
highlightHtml = hljs.highlight(code, {language: language, ignoreIllegals: true}).value
} catch (e) {
// skip
}
const copyHtml = `<div id="copy" data-copy='${code}' style="position: absolute; right: 10px; top: 5px; color: #fff;cursor: pointer;">复制</div>`
return `<pre style="position: relative;">${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);
//
// copy
contentRef.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">
.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>

View File

@ -72,7 +72,7 @@ $prefix-cls: #{$namespace}-layout;
.#{$prefix-cls} {
background-color: var(--app-content-bg-color);
:deep(.#{$elNamespace}-scrollbar__view) {
height: 100% !important;
height: 99% !important;
}
}
</style>

View File

@ -83,7 +83,7 @@ router.beforeEach(async (to, from, next) => {
const redirectPath = from.query.redirect || to.path
// 修复跳转时不带参数的问题
const redirect = decodeURIComponent(redirectPath as string)
const { basePath, paramsObject: query } = parseURL(redirect)
const { paramsObject: query } = parseURL(redirect)
const nextData = to.path === redirect ? { ...to, replace: true } : { path: redirect, query }
next(nextData)
} else {

View File

@ -1,8 +1,8 @@
/**
*
*/
import {useDictStoreWithOut} from '@/store/modules/dict'
import {ElementPlusInfoType} from '@/types/elementPlus'
import { useDictStoreWithOut } from '@/store/modules/dict'
import { ElementPlusInfoType } from '@/types/elementPlus'
const dictStore = useDictStoreWithOut()
@ -24,6 +24,10 @@ export interface NumberDictDataType extends DictDataType {
value: number
}
export interface StringDictDataType extends DictDataType {
value: string
}
export const getDictOptions = (dictType: string) => {
return dictStore.getDictByType(dictType) || []
}
@ -44,8 +48,11 @@ export const getIntDictOptions = (dictType: string): NumberDictDataType[] => {
}
export const getStrDictOptions = (dictType: string) => {
const dictOption: DictDataType[] = []
// 获得通用的 DictDataType 列表
const dictOptions: DictDataType[] = getDictOptions(dictType)
// 转换成 string 类型的 StringDictDataType 类型
// why 需要特殊转换:避免 IDEA 在 v-for="dict in getStrDictOptions(...)" 时el-option 的 key 会告警
const dictOption: StringDictDataType[] = []
dictOptions.forEach((dict: DictDataType) => {
dictOption.push({
...dict,
@ -209,5 +216,8 @@ export enum DICT_TYPE {
// ========== ERP - 企业资源计划模块 ==========
ERP_AUDIT_STATUS = 'erp_audit_status', // ERP 审批状态
ERP_STOCK_RECORD_BIZ_TYPE = 'erp_stock_record_biz_type' // 库存明细的业务类型
ERP_STOCK_RECORD_BIZ_TYPE = 'erp_stock_record_biz_type', // 库存明细的业务类型
// ========== AI - 人工智能模块 ==========
AI_PLATFORM = 'ai_platform' // AI 平台
}

View File

@ -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')
}

View File

@ -0,0 +1,79 @@
<template>
<div class="chat-empty">
<!-- title -->
<div class="center-container">
<div class="title">芋艿 AI</div>
<div class="role-list">
<div class="role-item" v-for="prompt in promptList" :key="prompt.prompt" @click="handlerPromptClick(prompt)">
{{prompt.prompt}}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const promptList = ref<any[]>() //
promptList.value = [
{
"prompt": "今天气怎么样?",
},
{
"prompt": "写一首好听的诗歌?",
}
]
const emits = defineEmits(['onPrompt'])
const handlerPromptClick = async ({ prompt }) => {
emits('onPrompt', prompt)
}
</script>
<style scoped lang="scss">
.chat-empty {
position: relative;
display: flex;
flex-direction: row;
justify-content: center;
width: 100%;
height: 100%;
.center-container {
display: flex;
flex-direction: column;
justify-content: center;
.title {
font-size: 28px;
font-weight: bold;
text-align: center;
}
.role-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: center;
width: 460px;
margin-top: 20px;
.role-item {
display: flex;
justify-content: center;
width: 180px;
line-height: 50px;
border: 1px solid #e4e4e4;
border-radius: 10px;
margin: 10px;
cursor: pointer;
}
.role-item:hover {
background-color: rgba(243, 243, 243, 0.73);
}
}
}
}
</style>

View File

@ -0,0 +1,521 @@
<!-- AI 对话 -->
<template>
<el-aside width="260px" class="conversation-container" style="height: 100%;">
<!-- 左顶部对话 -->
<div style="height: 100%;">
<el-button class="w-1/1 btn-new-conversation" type="primary" @click="createConversation">
<Icon icon="ep:plus" class="mr-5px"/>
新建对话
</el-button>
<!-- 左顶部搜索对话 -->
<el-input
v-model="searchName"
size="large"
class="mt-10px search-input"
placeholder="搜索历史记录"
@keyup="searchConversation"
>
<template #prefix>
<Icon icon="ep:search"/>
</template>
</el-input>
<!-- 左中间对话列表 -->
<div class="conversation-list">
<el-empty v-if="loading" description="." :v-loading="loading" />
<div v-for="conversationKey in Object.keys(conversationMap)" :key="conversationKey">
<div class="conversation-item classify-title" v-if="conversationMap[conversationKey].length">
<el-text class="mx-1" size="small" tag="b">{{ conversationKey }}</el-text>
</div>
<div
class="conversation-item"
v-for="conversation in conversationMap[conversationKey]"
:key="conversation.id"
@click="handleConversationClick(conversation.id)"
@mouseover="hoverConversationId = conversation.id"
@mouseout="hoverConversationId = ''"
>
<div
:class="conversation.id === activeConversationId ? 'conversation active' : 'conversation'"
>
<div class="title-wrapper">
<img class="avatar" :src="conversation.roleAvatar || roleAvatarDefaultImg"/>
<span class="title">{{ conversation.title }}</span>
</div>
<div class="button-wrapper" v-show="hoverConversationId === conversation.id">
<el-button class="btn" link @click.stop="handlerTop(conversation)" >
<el-icon title="置顶" v-if="!conversation.pinned"><Top /></el-icon>
<el-icon title="置顶" v-if="conversation.pinned"><Bottom /></el-icon>
</el-button>
<el-button class="btn" link @click.stop="updateConversationTitle(conversation)">
<el-icon title="编辑" >
<Icon icon="ep:edit"/>
</el-icon>
</el-button>
<el-button class="btn" link @click.stop="deleteChatConversation(conversation)">
<el-icon title="删除对话" >
<Icon icon="ep:delete"/>
</el-icon>
</el-button>
</div>
</div>
</div>
</div>
<!-- 底部站位 -->
<div style="height: 160px; width: 100%;"></div>
</div>
</div>
<!-- 左底部工具栏 -->
<!-- TODO @fan下面两个 icon可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 -->
<div class="tool-box">
<div @click="handleRoleRepository">
<Icon icon="ep:user"/>
<el-text size="small">角色仓库</el-text>
</div>
<div @click="handleClearConversation">
<Icon icon="ep:delete"/>
<el-text size="small">清空未置顶对话</el-text>
</div>
</div>
<!-- ============= 额外组件 ============= -->
<!-- 角色仓库抽屉 -->
<el-drawer v-model="drawer" title="角色仓库" size="754px">
<Role/>
</el-drawer>
</el-aside>
</template>
<script setup lang="ts">
import {ChatConversationApi, ChatConversationVO} from '@/api/ai/chat/conversation'
import {ref} from "vue";
import Role from "@/views/ai/chat/role/index.vue";
import {Bottom, Top} from "@element-plus/icons-vue";
import roleAvatarDefaultImg from '@/assets/ai/gpt.svg'
const message = useMessage() //
//
const searchName = ref<string>('') //
const activeConversationId = ref<string | null>(null) // null
const hoverConversationId = ref<string | null>(null) //
const conversationList = ref([] as ChatConversationVO[]) //
const conversationMap = ref<any>({}) // ()
const drawer = ref<boolean>(false) // TODO @fanroleDrawer
const loading = ref<boolean>(false) //
const loadingTime = ref<any>() //
// props
const props = defineProps({
activeId: {
type: String || null,
required: true
}
})
//
const emits = defineEmits([
'onConversationCreate',
'onConversationClick',
'onConversationClear',
'onConversationDelete'
])
/**
* 对话 - 搜索
*/
const searchConversation = async (e) => {
//
if (!searchName.value.trim().length) {
conversationMap.value = await conversationTimeGroup(conversationList.value)
} else {
//
const filterValues = conversationList.value.filter(item => {
return item.title.includes(searchName.value.trim())
})
conversationMap.value = await conversationTimeGroup(filterValues)
}
}
/**
* 对话 - 点击
*/
const handleConversationClick = async (id: string) => {
//
const filterConversation = conversationList.value.filter(item => {
return item.id === id
})
// onConversationClick
// TODO @fan: idea
const res = emits('onConversationClick', filterConversation[0])
//
if (res) {
activeConversationId.value = id
}
}
/**
* 对话 - 获取列表
*/
const getChatConversationList = async () => {
try {
// 0.
loadingTime.value = setTimeout(() => {
loading.value = true
}, 50)
// 1.
const res = await ChatConversationApi.getChatConversationMyList()
// 2.
res.sort((a, b) => {
return b.createTime - a.createTime
})
conversationList.value = res
// 3.
if (!activeId?.value) {
// await handleConversationClick(res[0].id)
} else {
// tip:
// const filterConversationList = conversationList.value.filter(item => {
// return item.id === activeId.value
// })
// if (filterConversationList.length <= 0) {
// await handleConversationClick(res[0].id)
// }
}
// 4.
if (conversationList.value.length === 0) {
activeConversationId.value = null
conversationMap.value = {}
return
}
// 5. (30)
conversationMap.value = await conversationTimeGroup(conversationList.value)
} finally {
//
if (loadingTime.value) {
clearTimeout(loadingTime.value)
}
//
loading.value = false
}
}
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;
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)
}
}
console.log('----groupMap', groupMap)
return groupMap
}
/**
* 对话 - 新建
*/
const createConversation = async () => {
// 1.
const conversationId = await ChatConversationApi.createChatConversationMy(
{} as unknown as ChatConversationVO
)
// 2.
await getChatConversationList()
// 3.
await handleConversationClick(conversationId)
// 4.
emits('onConversationCreate')
}
/**
* 对话 - 更新标题
*/
const updateConversationTitle = async (conversation: ChatConversationVO) => {
// 1.
const {value} = await ElMessageBox.prompt('修改标题', {
inputPattern: /^[\s\S]*.*\S[\s\S]*$/, //
inputErrorMessage: '标题不能为空',
inputValue: conversation.title
})
// 2.
await ChatConversationApi.updateChatConversationMy({
id: conversation.id,
title: value
} as ChatConversationVO)
message.success('重命名成功')
// 3.
await getChatConversationList()
// 4.
const filterConversationList = conversationList.value.filter(item => {
return item.id === conversation.id
})
if (filterConversationList.length > 0) {
// tip
if (activeConversationId.value === filterConversationList[0].id) {
emits('onConversationClick', filterConversationList[0])
}
}
}
/**
* 删除聊天对话
*/
const deleteChatConversation = async (conversation: ChatConversationVO) => {
try {
//
await message.delConfirm(`是否确认删除对话 - ${conversation.title}?`)
//
await ChatConversationApi.deleteChatConversationMy(conversation.id)
message.success('对话已删除')
//
await getChatConversationList()
//
emits('onConversationDelete', conversation)
} catch {
}
}
/**
* 对话置顶
*/
// TODO @fan handleXXXhandler
const handlerTop = async (conversation: ChatConversationVO) => {
//
conversation.pinned = !conversation.pinned
await ChatConversationApi.updateChatConversationMy(conversation)
//
await getChatConversationList()
}
// TODO @fan: ============ ============
// ============
/**
* 角色仓库抽屉
*/
const handleRoleRepository = async () => {
drawer.value = !drawer.value
}
// =============
/**
* 清空对话
*/
const handleClearConversation = async () => {
// TODO @fan使 await message.confirm( 使 await
ElMessageBox.confirm(
'确认后对话会全部清空,置顶的对话除外。',
'确认提示',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
await ChatConversationApi.deleteMyAllExceptPinned()
ElMessage({
message: '操作成功!',
type: 'success'
})
//
activeConversationId.value = null
//
await getChatConversationList()
//
emits('onConversationClear')
})
.catch(() => {
})
}
// ============ onMounted
const { activeId } = toRefs(props)
watch(activeId, async (newValue, oldValue) => {
//
activeConversationId.value = newValue as string
})
// public
defineExpose({createConversation})
onMounted(async () => {
//
await getChatConversationList()
//
if (props.activeId != null) {
activeConversationId.value = props.activeId
} else {
//
if (conversationList.value.length) {
activeConversationId.value = conversationList.value[0].id
// onConversationClick
await emits('onConversationClick', conversationList.value[0])
}
}
})
</script>
<style scoped lang="scss">
.conversation-container {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 0 10px;
padding-top: 10px;
overflow: hidden;
.btn-new-conversation {
padding: 18px 0;
}
.search-input {
margin-top: 20px;
}
.conversation-list {
overflow: auto;
height: 100%;
.classify-title {
padding-top: 10px;
}
.conversation-item {
margin-top: 5px;
}
.conversation {
display: flex;
flex-direction: row;
justify-content: space-between;
flex: 1;
padding: 0 5px;
cursor: pointer;
border-radius: 5px;
align-items: center;
line-height: 30px;
&.active {
background-color: #e6e6e6;
.button {
display: inline-block;
}
}
.title-wrapper {
display: flex;
flex-direction: row;
align-items: center;
}
.title {
padding: 2px 10px;
max-width: 220px;
font-size: 14px;
font-weight: 400;
color: rgba(0, 0, 0, 0.77);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.avatar {
width: 25px;
height: 25px;
border-radius: 5px;
display: flex;
flex-direction: row;
justify-items: center;
}
//
.button-wrapper {
right: 2px;
display: flex;
flex-direction: row;
justify-items: center;
color: #606266;
.btn {
margin: 0;
}
}
}
}
//
.tool-box {
position: absolute;
bottom: 0;
left: 0;
right: 0;
//width: 100%;
padding: 0 20px;
background-color: #f4f4f4;
box-shadow: 0 0 1px 1px rgba(228, 228, 228, 0.8);
line-height: 35px;
display: flex;
justify-content: space-between;
align-items: center;
color: var(--el-text-color);
> div {
display: flex;
align-items: center;
color: #606266;
padding: 0;
margin: 0;
cursor: pointer;
> span {
margin-left: 5px;
}
}
}
}
</style>

View File

@ -0,0 +1,322 @@
<template>
<div ref="messageContainer" style="height: 100%; overflow-y: auto; position: relative">
<div class="chat-list" v-for="(item, index) in list" :key="index">
<!-- 靠左 message -->
<div class="left-message message-item" v-if="item.type !== 'user'">
<div class="avatar">
<el-avatar :src="roleAvatar" />
</div>
<div class="message">
<div>
<el-text class="time">{{ formatDate(item.createTime) }}</el-text>
</div>
<div class="left-text-container" ref="markdownViewRef">
<MarkdownView class="left-text" :content="item.content" />
</div>
<div class="left-btns">
<el-button class="btn-cus" link @click="noCopy(item.content)">
<img class="btn-image" src="@/assets/ai/copy.svg" />
</el-button>
<el-button class="btn-cus" link @click="onDelete(item.id)">
<img class="btn-image" src="@/assets/ai/delete.svg" style="height: 17px" />
</el-button>
</div>
</div>
</div>
<!-- 靠右 message -->
<div class="right-message message-item" v-if="item.type === 'user'">
<div class="avatar">
<el-avatar :src="userAvatar" />
</div>
<div class="message">
<div>
<el-text class="time">{{ formatDate(item.createTime) }}</el-text>
</div>
<div class="right-text-container">
<div class="right-text">{{ item.content }}</div>
</div>
<div class="right-btns">
<el-button class="btn-cus" link @click="noCopy(item.content)">
<img class="btn-image" src="@/assets/ai/copy.svg" />
</el-button>
<el-button class="btn-cus" link @click="onDelete(item.id)">
<img
class="btn-image"
src="@/assets/ai/delete.svg"
style="height: 17px; margin-right: 12px"
/>
</el-button>
<el-button class="btn-cus" link @click="onRefresh(item)">
<el-icon size="17"><RefreshRight /></el-icon>
</el-button>
<el-button class="btn-cus" link @click="onEdit(item)">
<el-icon size="17"><Edit /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 回到底部 -->
<div v-if="isScrolling" class="to-bottom" @click="handleGoBottom">
<el-button :icon="ArrowDownBold" circle />
</div>
</template>
<script setup lang="ts">
import { formatDate } from '@/utils/formatTime'
import MarkdownView from '@/components/MarkdownView/index.vue'
import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
import { useClipboard } from '@vueuse/core'
import { PropType } from 'vue'
import { ArrowDownBold, Edit, RefreshRight } from '@element-plus/icons-vue'
import { ChatConversationVO } from '@/api/ai/chat/conversation'
import {useUserStore} from '@/store/modules/user';
import userAvatarDefaultImg from '@/assets/imgs/avatar.gif'
import roleAvatarDefaultImg from '@/assets/ai/gpt.svg'
const { copy } = useClipboard() // copy
// ()
const messageContainer: any = ref(null)
const isScrolling = ref(false) //
const userStore = useUserStore()
const userAvatar = computed(() => userStore.user.avatar ?? userAvatarDefaultImg)
const roleAvatar = computed(() => props.conversation.roleAvatar ?? roleAvatarDefaultImg)
// props
const props = defineProps({
conversation: {
type: Object as PropType<ChatConversationVO>,
required: true
},
list: {
type: Array as PropType<ChatMessageVO[]>,
required: true
}
})
// ============ ==============
const scrollToBottom = async (isIgnore?: boolean) => {
await nextTick(() => {
// TODO @fannextick idea 绿线
//使nexttickdom
if (isIgnore || !isScrolling.value) {
messageContainer.value.scrollTop =
messageContainer.value.scrollHeight - messageContainer.value.offsetHeight
}
})
}
function handleScroll() {
const scrollContainer = messageContainer.value
const scrollTop = scrollContainer.scrollTop
const scrollHeight = scrollContainer.scrollHeight
const offsetHeight = scrollContainer.offsetHeight
if (scrollTop + offsetHeight < scrollHeight - 100) {
//
isScrolling.value = true
} else {
//
isScrolling.value = false
}
}
/**
* 复制
*/
const noCopy = async (content) => {
copy(content)
ElMessage({
message: '复制成功!',
type: 'success'
})
}
/**
* 删除
*/
const onDelete = async (id) => {
// message
await ChatMessageApi.delete(id)
ElMessage({
message: '删除成功!',
type: 'success'
})
//
emits('onDeleteSuccess')
}
/**
* 刷新
*/
const onRefresh = async (message: ChatMessageVO) => {
emits('onRefresh', message)
}
/**
* 编辑
*/
const onEdit = async (message: ChatMessageVO) => {
emits('onEdit', message)
}
/**
* 回到底部
*/
const handleGoBottom = async () => {
const scrollContainer = messageContainer.value
scrollContainer.scrollTop = scrollContainer.scrollHeight
}
/**
* 回到顶部
*/
const handlerGoTop = async () => {
const scrollContainer = messageContainer.value
scrollContainer.scrollTop = 0
}
// list
// TODO @fan
const { list, conversationId } = toRefs(props)
watch(list, async (newValue, oldValue) => {
console.log('watch list', list)
})
// parent
defineExpose({ scrollToBottom, handlerGoTop })
// emits
const emits = defineEmits(['onDeleteSuccess', 'onRefresh', 'onEdit'])
// onMounted
onMounted(async () => {
messageContainer.value.addEventListener('scroll', handleScroll)
})
</script>
<style scoped lang="scss">
.message-container {
position: relative;
//top: 0;
//bottom: 0;
//left: 0;
//right: 0;
//width: 100%;
//height: 100%;
overflow-y: scroll;
//padding: 0 15px;
//z-index: -1;
}
//
.chat-list {
display: flex;
flex-direction: column;
overflow-y: hidden;
padding: 0 20px;
.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 {
position: relative;
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;
white-space: pre-wrap;
}
}
.left-btns {
display: flex;
flex-direction: row;
margin-top: 8px;
}
.right-btns {
display: flex;
flex-direction: row-reverse;
margin-top: 8px;
}
}
//
.btn-cus {
display: flex;
background-color: transparent;
align-items: center;
.btn-image {
height: 20px;
}
}
.btn-cus:hover {
cursor: pointer;
background-color: #f6f6f6;
}
}
//
.to-bottom {
position: absolute;
z-index: 1000;
bottom: 0;
right: 50%;
}
</style>

View File

@ -0,0 +1,15 @@
<!-- message 加载页面 -->
<template>
<div class="message-loading" >
<el-skeleton animated />
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.message-loading {
padding: 30px 30px;
}
</style>

View File

@ -0,0 +1,50 @@
<!-- message 新增对话 -->
<template>
<div class="new-chat" >
<div class="box-center">
<div class="tip">点击下方按钮开始你的对话吧</div>
<div class="btns"><el-button type="primary" round @click="handlerNewChat">新建对话</el-button></div>
</div>
</div>
</template>
<script setup lang="ts">
//
const emits = defineEmits(['onNewChat'])
/**
* 新建 chat
*/
const handlerNewChat = async () => {
await emits('onNewChat')
}
</script>
<style scoped lang="scss">
.new-chat {
display: flex;
flex-direction: row;
justify-content: center;
width: 100%;
height: 100%;
.box-center {
display: flex;
flex-direction: column;
justify-content: center;
.tip {
font-size: 14px;
color: #858585;
}
.btns {
display: flex;
flex-direction: row;
justify-content: center;
margin-top: 20px;
}
}
}
</style>

View File

@ -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" rows="4" 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) // 12
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>

View File

@ -0,0 +1,48 @@
<!-- 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>

View File

@ -1,125 +1,676 @@
<template>
<el-container>
<!-- 左侧会话列表 -->
<el-aside width="260px" class="conversation-container">
<!-- 左顶部新建对话 -->
<el-button class="w-1/1" type="primary">
<Icon icon="ep:plus" class="mr-5px" />
新建对话
</el-button>
<!-- 左顶部搜索对话 -->
<el-input
v-model="searchName"
class="mt-10px"
placeholder="搜索历史记录"
@keyup="searchConversation"
>
<template #prefix>
<Icon icon="ep:search" />
</template>
</el-input>
<!-- 左中间对话列表 -->
<div class="conversation-list" :style="{ height: leftHeight + 'px' }">
<el-row v-for="conversation in conversationList" :key="conversation.id">
<div
:class="conversation.id === conversationId ? 'conversation active' : 'conversation'"
@click="changeConversation(conversation)"
>
<el-image :src="conversation.avatar" class="avatar" />
<span class="title">{{ conversation.title }}</span>
<span class="button">
<!-- TODO 芋艿缺置顶按钮 -->
<el-icon title="编辑" @click="updateConversationTitle(conversation)">
<Icon icon="ep:edit" />
</el-icon>
<el-icon title="删除会话" @click="deleteConversationTitle(conversation)">
<Icon icon="ep:delete" />
</el-icon>
</span>
</div>
</el-row>
</div>
<!-- 左底部工具栏 TODO 芋艿50% 不太对 -->
<div class="tool-box">
<sapn class="w-1/2"> <Icon icon="ep:user" /> 角色仓库 </sapn>
<sapn class="w-1/2"> <Icon icon="ep:delete" />清空未置顶对话</sapn>
</div>
</el-aside>
<!-- 右侧会话详情 -->
<el-container class="ai-layout">
<!-- 左侧对话列表 -->
<Conversation
:active-id="activeConversationId"
ref="conversationRef"
@onConversationCreate="handleConversationCreate"
@onConversationClick="handleConversationClick"
@onConversationClear="handlerConversationClear"
@onConversationDelete="handlerConversationDelete"
/>
<!-- 右侧对话详情 -->
<el-container class="detail-container">
<!-- 右顶部 TODO 芋艿右对齐 -->
<el-header class="header">
<el-button>3.5-turbo-0125 <Icon icon="ep:setting" /></el-button>
<el-button>
<Icon icon="ep:user" />
</el-button>
<el-button>
<Icon icon="ep:download" />
</el-button>
<el-button>
<Icon icon="ep:arrow-up" />
</el-button>
<div class="title">
{{ activeConversation?.title ? activeConversation?.title : '对话' }}
<span v-if="list.length">({{ list.length }})</span>
</div>
<div class="btns" v-if="activeConversation">
<el-button type="primary" bg plain size="small" @click="openChatConversationUpdateForm">
<span v-html="activeConversation?.modelName"></span>
<Icon icon="ep:setting" style="margin-left: 10px" />
</el-button>
<el-button size="small" class="btn" @click="handlerMessageClear">
<!-- TODO @fanstyle 部分可以考虑用 unocss 替代 -->
<img src="@/assets/ai/clear.svg" style="height: 14px" />
</el-button>
<!-- TODO @fan下面两个 icon可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 -->
<el-button size="small" :icon="Download" class="btn" />
<el-button size="small" :icon="Top" class="btn" @click="handlerGoTop" />
</div>
</el-header>
<el-main>对话列表</el-main>
<el-footer>发送消息框</el-footer>
<!-- main消息列表 -->
<el-main class="main-container">
<div>
<div class="message-container">
<MessageLoading v-if="listLoading" />
<MessageNewChat v-if="!activeConversation" @on-new-chat="handlerNewChat" />
<ChatEmpty
v-if="!listLoading && messageList.length === 0 && activeConversation"
@on-prompt="doSend"
/>
<Message
v-if="!listLoading && messageList.length > 0"
ref="messageRef"
:conversation="activeConversation"
:list="messageList"
@on-delete-success="handlerMessageDelete"
@on-edit="handlerMessageEdit"
@on-refresh="handlerMessageRefresh"
/>
</div>
</div>
</el-main>
<!-- 底部 -->
<el-footer class="footer-container">
<form class="prompt-from">
<textarea
class="prompt-input"
v-model="prompt"
@keydown="onSend"
@input="onPromptInput"
@compositionstart="onCompositionstart"
@compositionend="onCompositionend"
placeholder="问我任何问题...Shift+Enter 换行,按下 Enter 发送)"
></textarea>
<div class="prompt-btns">
<div>
<el-switch v-model="enableContext" />
<span style="font-size: 14px; color: #8f8f8f">上下文</span>
</div>
<el-button
type="primary"
size="default"
@click="onSendBtn"
: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>
<!-- ========= 额外组件 ========== -->
<!-- 更新对话 Form -->
<ChatConversationUpdateForm
ref="chatConversationUpdateFormRef"
@success="handlerTitleSuccess"
/>
</el-container>
</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'
}
]
const conversationId = ref(1)
const searchName = ref('')
const leftHeight = window.innerHeight - 240 // TODO
// TODO @fan index.vue index /ai/chat /ai/chat/manager
import Conversation from './Conversation.vue'
import Message from './Message.vue'
import ChatEmpty from './ChatEmpty.vue'
import MessageLoading from './MessageLoading.vue'
import MessageNewChat from './MessageNewChat.vue'
import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
import ChatConversationUpdateForm from '@/views/ai/chat/components/ChatConversationUpdateForm.vue'
import { Download, Top } from '@element-plus/icons-vue'
const changeConversation = (conversation) => {
console.log(conversation)
conversationId.value = conversation.id
// TODO
}
const route = useRoute() //
const message = useMessage() //
const updateConversationTitle = (conversation) => {
console.log(conversation)
// TODO
}
// ref
const activeConversationId = ref<string | null>(null) //
const activeConversation = ref<ChatConversationVO | null>(null) // Conversation
const conversationInProgress = ref(false) //
const conversationInAbortController = ref<any>() // abort ( stream )
const inputTimeout = ref<any>() //
const prompt = ref<string>() // prompt
const enableContext = ref<boolean>(true) //
const deleteConversationTitle = (conversation) => {
console.log(conversation)
// TODO
}
// TODO @fanfullText Text
const fullText = ref('')
const displayedText = ref('')
const textSpeed = ref<number>(50) // Typing speed in milliseconds
const textRoleRunning = ref<boolean>(false) // Typing speed in milliseconds
const searchConversation = () => {
// TODO
}
</script>
<style lang="scss" scoped>
.conversation-container {
.conversation-list {
.conversation {
display: flex;
justify-content: flex-start;
width: 100%;
padding: 5px 5px 0 0;
cursor: pointer;
&.active {
// TODO
background-color: #343540;
.button {
display: inline;
// chat message
// TODO @fanlistlistLoadinglistLoadingTime
const list = ref<ChatMessageVO[]>([]) //
const listLoading = ref<boolean>(false) //
const listLoadingTime = ref<any>() // time
// ()
const messageRef = ref()
const conversationRef = ref()
const isComposing = ref(false) //
// role
const defaultRoleAvatar =
'http://test.yudao.iocoder.cn/eaef5f41acb911dd718429a0702dcc3c61160d16e57ba1d543132fab58934f9f.png'
// ===========
// TODO @fan
const textRoll = async () => {
let index = 0
try {
//
if (textRoleRunning.value) {
return
}
//
textRoleRunning.value = true
displayedText.value = ''
const task = async () => {
//
const diff = (fullText.value.length - displayedText.value.length) / 10
if (diff > 5) {
textSpeed.value = 10
} else if (diff > 2) {
textSpeed.value = 30
} else if (diff > 1.5) {
textSpeed.value = 50
} else {
textSpeed.value = 100
}
// 30
if (!conversationInProgress.value) {
textSpeed.value = 10
}
// console.log('index < fullText.value.length', index < fullText.value.length, conversationInProgress.value)
if (index < fullText.value.length) {
displayedText.value += fullText.value[index]
index++
// message
const lastMessage = list.value[list.value.length - 1]
lastMessage.content = displayedText.value
// TODO @fanist.value ist.value.length
list.value[list.value - 1] = lastMessage
//
await scrollToBottom()
//
timer = setTimeout(task, textSpeed.value)
} else {
//
if (!conversationInProgress.value) {
textRoleRunning.value = false
clearTimeout(timer)
console.log('字体滚动退出!')
} else {
//
timer = setTimeout(task, textSpeed.value)
}
}
}
let timer = setTimeout(task, textSpeed.value)
} finally {
}
}
// ============ ==============
function scrollToBottom(isIgnore?: boolean) {
// isIgnore = isIgnore !== null ? isIgnore : false
nextTick(() => {
if (messageRef.value) {
messageRef.value.scrollToBottom(isIgnore)
}
})
}
// ============= =============
// TODO @fan @keydown.enter@keydown.shift.enter shift+ isComposing
const onCompositionstart = () => {
isComposing.value = true
}
const onCompositionend = () => {
// console.log('...')
setTimeout(() => {
isComposing.value = false
}, 200)
}
const onPromptInput = (event) => {
// true
if (!isComposing.value) {
// event data null
if (event.data == null) {
return
}
isComposing.value = true
}
//
if (inputTimeout.value) {
clearTimeout(inputTimeout.value)
}
//
inputTimeout.value = setTimeout(() => {
isComposing.value = false
}, 400)
}
// ============== =================
/**
* 发送消息
*/
const onSend = async (event) => {
//
if (isComposing.value) {
return
}
//
if (conversationInProgress.value) {
return
}
const content = prompt.value?.trim() as string
if (event.key === 'Enter') {
if (event.shiftKey) {
//
prompt.value += '\r\n'
event.preventDefault() //
} else {
//
await doSend(content)
event.preventDefault() //
}
}
}
const onSendBtn = async () => {
await doSend(prompt.value?.trim() as string)
}
const doSend = async (content: string) => {
if (content.length < 2) {
// TODO @fan message.error(`${props.fileSize}MB!`)
ElMessage({
message: '请输入内容!',
type: 'error'
})
return
}
// TODO @fan message.error(`${props.fileSize}MB!`)
if (activeConversationId.value == null) {
ElMessage({
message: '还没创建对话,不能发送!',
type: 'error'
})
return
}
//
prompt.value = ''
// TODO @fanidea
const userMessage = {
conversationId: activeConversationId.value,
content: content
} as ChatMessageVO
// stream
await doSendStream(userMessage)
}
const doSendStream = async (userMessage: ChatMessageVO) => {
// AbortController便
conversationInAbortController.value = new AbortController()
//
conversationInProgress.value = true
//
fullText.value = ''
try {
// stream
list.value.push({
id: -1,
conversationId: activeConversationId.value,
type: 'user',
content: userMessage.content,
createTime: new Date()
} as ChatMessageVO)
list.value.push({
id: -2,
conversationId: activeConversationId.value,
type: 'system',
content: '思考中...',
createTime: new Date()
} as ChatMessageVO)
//
// TODO @fan await nextTick() scrollToBottom()
nextTick(async () => {
await scrollToBottom()
})
//
textRoll()
// event stream
let isFirstMessage = true // TODO @fanisFirstChunk
ChatMessageApi.sendStream(
userMessage.conversationId, // TODO
userMessage.content,
conversationInAbortController.value,
enableContext.value,
async (res) => {
console.log('res', res)
const { code, data, msg } = JSON.parse(res.data)
if (code !== 0) {
message.alert(`对话异常! ${msg}`)
return
}
//
if (data.receive.content === '') {
return
}
// message
if (isFirstMessage) {
isFirstMessage = false
//
list.value.pop()
list.value.pop()
//
list.value.push(data.send)
list.value.push(data.receive)
}
// debugger
fullText.value = fullText.value + data.receive.content
//
await scrollToBottom()
},
(error) => {
message.alert(`对话异常! ${error}`)
// TODO @fan stopStream
//
conversationInProgress.value = false
// stream
conversationInAbortController.value.abort()
},
() => {
// TODO @fan stopStream
//
conversationInProgress.value = false
// stream
conversationInAbortController.value.abort()
}
)
} finally {
}
}
const stopStream = async () => {
console.log('stopStream...')
// tip stream message controller
if (conversationInAbortController.value) {
conversationInAbortController.value.abort()
}
// false
conversationInProgress.value = false
}
// ============== message =================
/** 消息列表 */
const messageList = computed(() => {
if (list.value.length > 0) {
return list.value
}
// systemMessage
// TODO add by
if (activeConversation.value?.systemMessage) {
return [
{
id: 0,
type: 'system',
content: activeConversation.value.systemMessage
}
]
}
return []
})
// TODO @fan /** */= =
/**
* 获取 - message 列表
*/
const getMessageList = async () => {
try {
// time
listLoadingTime.value = setTimeout(() => {
listLoading.value = true
}, 60)
if (activeConversationId.value === null) {
return
}
//
list.value = await ChatMessageApi.messageList(activeConversationId.value)
//
await nextTick(() => {
//
scrollToBottom()
})
} finally {
// time
if (listLoadingTime.value) {
clearTimeout(listLoadingTime.value)
}
//
listLoading.value = false
}
}
/** 修改聊天对话 */
const chatConversationUpdateFormRef = ref()
const openChatConversationUpdateForm = async () => {
chatConversationUpdateFormRef.value.open(activeConversationId.value)
}
/**
* 对话 - 标题修改成功
*/
const handlerTitleSuccess = async () => {
// TODO
await getConversation(activeConversationId.value)
}
/**
* 对话 - 创建
*/
const handleConversationCreate = async () => {
//
prompt.value = ''
}
/**
* 对话 - 点击
*/
const handleConversationClick = async (conversation: ChatConversationVO) => {
//
if (conversationInProgress.value) {
await message.alert('对话中,不允许切换!')
return false
}
// id
activeConversationId.value = conversation.id
activeConversation.value = conversation
//
if (conversationInProgress.value) {
await stopStream()
}
// message
await getMessageList()
//
scrollToBottom(true)
//
prompt.value = ''
return true
}
/**
* 对话 - 清理全部对话
*/
const handlerConversationClear = async () => {
// TODO @fan
activeConversationId.value = null
activeConversation.value = null
list.value = []
}
/**
* 对话 - 删除
*/
const handlerConversationDelete = async (delConversation: ChatConversationVO) => {
//
if (activeConversationId.value === delConversation.id) {
await handlerConversationClear()
}
}
/**
* 对话 - 获取
*/
const getConversation = async (id: string | null) => {
if (!id) {
return
}
const conversation: ChatConversationVO = await ChatConversationApi.getChatConversationMy(id)
if (conversation) {
activeConversation.value = conversation
activeConversationId.value = conversation.id
}
}
/**
* 对话 - 新建
*/
// TODO @fan handleXXXhandler
const handlerNewChat = async () => {
//
await conversationRef.value.createConversation()
}
// ============ message ===========
/**
* 删除 message
*/
const handlerMessageDelete = async () => {
if (conversationInProgress.value) {
message.alert('回答中,不能删除!')
return
}
// message
await getMessageList()
}
/**
* 编辑 message设置为 prompt可以再次编辑
*/
const handlerMessageEdit = async (message: ChatMessageVO) => {
prompt.value = message.content
}
/**
* 刷新 message基于指定消息再次发起对话
*/
const handlerMessageRefresh = async (message: ChatMessageVO) => {
await doSend(message.content)
}
/**
* 回到顶部
*/
const handlerGoTop = async () => {
await messageRef.value.handlerGoTop()
}
/**
* message 清除
*/
const handlerMessageClear = async () => {
if (!activeConversationId.value) {
return
}
// TODO @fan try catch
//
await message.delConfirm('确认清空对话消息?')
//
await ChatMessageApi.deleteByConversationId(activeConversationId.value as string)
// TODO @fan
// message
await getMessageList()
}
/** 初始化 **/
onMounted(async () => {
// TODO conversationId
if (route.query.conversationId) {
const id = route.query.conversationId as string
activeConversationId.value = id
await getConversation(id)
}
//
listLoading.value = true
await getMessageList()
})
</script>
<style lang="scss" scoped>
.ai-layout {
// TODO @ height 100%
position: absolute;
flex: 1;
top: 0;
left: 0;
height: 100%;
width: 100%;
}
.conversation-container {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 0 10px;
padding-top: 10px;
.btn-new-conversation {
padding: 18px 0;
}
.search-input {
margin-top: 20px;
}
.conversation-list {
margin-top: 20px;
.conversation {
display: flex;
flex-direction: row;
justify-content: space-between;
flex: 1;
padding: 0 5px;
margin-top: 10px;
cursor: pointer;
border-radius: 5px;
align-items: center;
line-height: 30px;
&.active {
background-color: #e6e6e6;
.button {
display: inline-block;
}
}
.title-wrapper {
display: flex;
flex-direction: row;
align-items: center;
}
.title {
padding: 5px 10px;
max-width: 220px;
@ -128,39 +679,144 @@ const searchConversation = () => {
white-space: nowrap;
text-overflow: ellipsis;
}
.avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
flex-direction: row;
justify-items: center;
}
.button {
position: absolute;
//
.button-wrapper {
right: 2px;
top: 16px;
display: flex;
flex-direction: row;
justify-items: center;
color: #606266;
.el-icon {
margin-right: 5px;
}
}
}
}
.tool-box {
//
.tool-box {
line-height: 35px;
display: flex;
justify-content: space-between;
align-items: center;
color: var(--el-text-color);
> div {
display: flex;
justify-content: flex-start;
padding: 0 20px 10px 20px;
border-top: 1px solid black;
align-items: center;
color: #606266;
padding: 0;
margin: 0;
cursor: pointer;
> span {
margin-left: 5px;
}
}
}
}
//
.detail-container {
background: #ffffff;
.header {
width: 100%;
height: 30px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding-top: 10px;
justify-content: space-between;
background: #fbfbfb;
box-shadow: 0 0 0 0 #dcdfe6;
.title {
font-size: 18px;
font-weight: bold;
}
.btns {
display: flex;
width: 300px;
flex-direction: row;
justify-content: flex-end;
//justify-content: space-between;
.btn {
padding: 10px;
}
}
}
}
// main
.main-container {
margin: 0;
padding: 0;
position: relative;
height: 100%;
width: 100%;
.message-container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
//width: 100%;
//height: 100%;
overflow-y: hidden;
padding: 0;
margin: 0;
}
}
//
.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: 10px 20px 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: auto;
}
.prompt-input:focus {
outline: none;
}
.prompt-btns {
display: flex;
justify-content: space-between;
padding-bottom: 0px;
padding-top: 5px;
}
}
</style>

View File

@ -0,0 +1,163 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="用户编号" prop="userId">
<el-select
v-model="queryParams.userId"
clearable
placeholder="请输入用户编号"
class="!w-240px"
>
<el-option
v-for="item in userList"
:key="item.id"
:label="item.nickname"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="聊天编号" prop="title">
<el-input
v-model="queryParams.title"
placeholder="请输入聊天编号"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="创建时间" prop="createTime">
<el-date-picker
v-model="queryParams.createTime"
value-format="YYYY-MM-DD HH:mm:ss"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
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-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="id" width="180" fixed="left" />
<el-table-column label="对话标题" align="center" prop="title" width="180" fixed="left" />
<el-table-column label="用户" align="center" prop="userId" width="180">
<template #default="scope">
<span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span>
</template>
</el-table-column>
<el-table-column label="角色" align="center" prop="roleName" width="180" />
<el-table-column label="模型标识" align="center" prop="model" width="180" />
<el-table-column label="消息数" align="center" prop="messageCount" />
<el-table-column
label="创建时间"
align="center"
prop="createTime"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="温度参数" align="center" prop="temperature" />
<el-table-column label="回复 Token 数" align="center" prop="maxTokens" width="120" />
<el-table-column label="上下文数量" align="center" prop="maxContexts" width="120" />
<el-table-column label="操作" align="center" width="180" fixed="right">
<template #default="scope">
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['ai:chat-conversation: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>
</template>
<script setup lang="ts">
import { dateFormatter } from '@/utils/formatTime'
import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
import * as UserApi from '@/api/system/user'
const message = useMessage() //
const { t } = useI18n() //
const loading = ref(true) //
const list = ref<ChatConversationVO[]>([]) //
const total = ref(0) //
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
userId: undefined,
title: undefined,
createTime: []
})
const queryFormRef = ref() //
const userList = ref<UserApi.UserVO[]>([]) //
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await ChatConversationApi.getChatConversationPage(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 handleDelete = async (id: number) => {
try {
//
await message.delConfirm()
//
await ChatConversationApi.deleteChatConversationByAdmin(id)
message.success(t('common.delSuccess'))
//
await getList()
} catch {}
}
/** 初始化 **/
onMounted(async () => {
getList()
//
userList.value = await UserApi.getSimpleUserList()
})
</script>

View File

@ -0,0 +1,175 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="对话编号" prop="conversationId">
<el-input
v-model="queryParams.conversationId"
placeholder="请输入对话编号"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="用户编号" prop="userId">
<el-select
v-model="queryParams.userId"
clearable
placeholder="请输入用户编号"
class="!w-240px"
>
<el-option
v-for="item in userList"
:key="item.id"
:label="item.nickname"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="创建时间" prop="createTime">
<el-date-picker
v-model="queryParams.createTime"
value-format="YYYY-MM-DD HH:mm:ss"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
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-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="id" width="180" fixed="left" />
<el-table-column
label="对话编号"
align="center"
prop="conversationId"
width="180"
fixed="left"
/>
<el-table-column label="用户" align="center" prop="userId" width="180">
<template #default="scope">
<span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span>
</template>
</el-table-column>
<el-table-column label="角色" align="center" prop="roleName" width="180" />
<el-table-column label="消息类型" align="center" prop="type" width="100" />
<el-table-column label="模型标识" align="center" prop="model" width="180" />
<el-table-column label="消息内容" align="center" prop="content" width="300" />
<el-table-column
label="创建时间"
align="center"
prop="createTime"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="回复消息编号" align="center" prop="replyId" width="180" />
<el-table-column label="携带上下文" align="center" prop="useContext" width="100">
<template #default="scope">
<dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.useContext" />
</template>
</el-table-column>
<el-table-column label="操作" align="center" fixed="right">
<template #default="scope">
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['ai:chat-message: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>
</template>
<script setup lang="ts">
import { dateFormatter } from '@/utils/formatTime'
import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
import * as UserApi from '@/api/system/user'
import { DICT_TYPE } from '@/utils/dict'
const message = useMessage() //
const { t } = useI18n() //
const loading = ref(true) //
const list = ref<ChatMessageVO[]>([]) //
const total = ref(0) //
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
conversationId: undefined,
userId: undefined,
content: undefined,
createTime: []
})
const queryFormRef = ref() //
const userList = ref<UserApi.UserVO[]>([]) //
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await ChatMessageApi.getChatMessagePage(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 handleDelete = async (id: number) => {
try {
//
await message.delConfirm()
//
await ChatMessageApi.deleteChatMessageByAdmin(id)
message.success(t('common.delSuccess'))
//
await getList()
} catch {}
}
/** 初始化 **/
onMounted(async () => {
getList()
//
userList.value = await UserApi.getSimpleUserList()
})
</script>

View File

@ -0,0 +1,20 @@
<template>
<ContentWrap>
<el-tabs>
<el-tab-pane label="对话列表">
<ChatConversationList />
</el-tab-pane>
<el-tab-pane label="消息列表">
<ChatMessageList />
</el-tab-pane>
</el-tabs>
</ContentWrap>
</template>
<script setup lang="ts">
import ChatConversationList from './ChatConversationList.vue'
import ChatMessageList from './ChatMessageList.vue'
/** AI 聊天对话 列表 */
defineOptions({ name: 'ChatConversation' })
</script>

View File

@ -0,0 +1,47 @@
<template>
<div class="category-list">
<div class="category" v-for="(category) in categoryList" :key="category">
<el-button plain round size="small" v-if="category !== active" @click="handleCategoryClick(category)">{{ category }}</el-button>
<el-button plain round size="small" v-else type="primary" @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,
default: '全部'
}
})
//
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: 10px;
}
}
</style>

View File

@ -0,0 +1,188 @@
<template>
<div class="card-list" ref="tabsRef" @scroll="handleTabsScroll">
<div class="card-item" v-for="role in roleList" :key="role.id">
<el-card class="card" body-class="card-body">
<!-- 更多 -->
<div class="more-container" v-if="showMore">
<el-dropdown @command="handleMoreClick">
<span class="el-dropdown-link">
<el-button type="text" >
<el-icon><More /></el-icon>
</el-button>
</span>
<!-- TODO @fan下面两个 icon可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 -->
<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>
</div>
</template>
<script setup lang="ts">
import {ChatRoleVO} from '@/api/ai/model/chatRole'
import {PropType, ref} from "vue";
import {Delete, EditPen, More} from "@element-plus/icons-vue";
const tabsRef = ref<any>() // tabs ref
//
const props = defineProps({
loading: {
type: Boolean,
required: true
},
roleList: {
type: Array as PropType<ChatRoleVO[]>,
required: true
},
showMore: {
type: Boolean,
required: false,
default: false
}
})
//
const emits = defineEmits(['onDelete', 'onEdit', 'onUse', 'onPage'])
// 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)
}
const handleTabsScroll = async () => {
if (tabsRef.value) {
const { scrollTop, scrollHeight, clientHeight } = tabsRef.value;
console.log('scrollTop', scrollTop)
if (scrollTop + clientHeight >= scrollHeight - 20 && !props.loading) {
console.log('分页')
// page.value++;
// fetchData(page.value);
await emits('onPage')
}
}
}
onMounted(() => {
console.log('props', props.roleList)
})
</script>
<style lang="scss">
// card body
.card-body {
max-width: 240px;
width: 240px;
padding: 15px 15px 10px 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;
height: 100%;
overflow: auto;
padding: 0px 25px;
padding-bottom: 140px;
align-items: start;
align-content: flex-start;
justify-content: start;
.card {
display: inline-block;
margin-right: 20px;
border-radius: 10px;
margin-bottom: 20px;
position: relative;
.more-container {
position: absolute;
top: 0;
right: 12px;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 10px;
overflow: hidden;
}
.right-container {
margin-left: 10px;
width: 100%;
//height: 100px;
.content-container {
height: 85px;
.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: 2px;
}
}
}
}
</style>

View File

@ -0,0 +1,296 @@
<!-- chat 角色仓库 -->
<template>
<el-container class="role-container">
<ChatRoleForm ref="formRef" @success="handlerAddRoleSuccess" />
<!-- header -->
<Header title="角色仓库" style="position: relative" />
<!-- main -->
<el-main class="role-main">
<div class="search-container">
<!-- 搜索按钮 -->
<el-input
:loading="loading"
v-model="search"
class="search-input"
size="default"
placeholder="请输入搜索的内容"
:suffix-icon="Search"
@change="getActiveTabsRole"
/>
<el-button
v-if="activeRole == 'my-role'"
type="primary"
@click="handlerAddRole"
style="margin-left: 20px"
>
<!-- TODO @fan下面两个 icon可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 -->
<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
:loading="loading"
:role-list="myRoleList"
:show-more="true"
@on-delete="handlerCardDelete"
@on-edit="handlerCardEdit"
@on-use="handlerCardUse"
@on-page="handlerCardPage('my')"
style="margin-top: 20px"
/>
</el-tab-pane>
<el-tab-pane label="公共角色" name="public-role">
<RoleCategoryList
class="role-category-list"
:category-list="categoryList"
:active="activeCategory"
@on-category-click="handlerCategoryClick"
/>
<RoleList
:role-list="publicRoleList"
@on-delete="handlerCardDelete"
@on-edit="handlerCardEdit"
@on-use="handlerCardUse"
@on-page="handlerCardPage('public')"
style="margin-top: 20px"
loading
/>
</el-tab-pane>
</el-tabs>
</el-main>
</el-container>
</template>
<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 loading = ref<boolean>(false) //
const activeRole = ref<string>('my-role') // TODO @fan activeTab
const search = ref<string>('') //
// TODO @fan myPagepubPage const queryParams = reactive({
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[]>([]) //
/** tabs 点击 */
const handleTabsClick = async (tab: TabsPaneContext) => {
//
activeRole.value = tab.paneName + ''
//
await getActiveTabsRole()
}
/** 获取 my role 我的角色 */
const getMyRole = async (append?: boolean) => {
const params: ChatRolePageReqVO = {
pageNo: myPageNo.value,
pageSize: myPageSize.value,
name: search.value,
publicStatus: false
}
const { total, list } = await ChatRoleApi.getMyPage(params)
if (append) {
myRoleList.value.push.apply(myRoleList.value, list)
} else {
myRoleList.value = list
}
}
/** 获取 public role 公共角色 */
const getPublicRole = async (append?: boolean) => {
const params: ChatRolePageReqVO = {
pageNo: publicPageNo.value,
pageSize: publicPageSize.value,
category: activeCategory.value === '全部' ? '' : activeCategory.value,
name: search.value,
publicStatus: true
}
const { total, list } = await ChatRoleApi.getMyPage(params)
if (append) {
publicRoleList.value.push.apply(publicRoleList.value, list)
} else {
publicRoleList.value = list
}
}
/** 获取选中的 tabs 角色 */
const getActiveTabsRole = async () => {
if (activeRole.value === 'my-role') {
myPageNo.value = 1
await getMyRole()
} else {
publicPageNo.value = 1
await getPublicRole()
}
}
/** 获取角色分类列表 */
const getRoleCategoryList = async () => {
const res = await ChatRoleApi.getCategoryList()
const defaultRole = ['全部']
categoryList.value = [...defaultRole, ...res]
}
/** 处理分类点击 */
const handlerCategoryClick = async (category: string) => {
//
activeCategory.value = category
//
await getActiveTabsRole()
}
/** 添加/修改操作 */
const formRef = ref()
const handlerAddRole = async () => {
formRef.value.open('my-create', null, '添加角色')
}
/** 添加角色成功 */
const handlerAddRoleSuccess = async (e) => {
//
await getActiveTabsRole()
}
// 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 handlerCardPage = async (type) => {
console.log('handlerCardPage', type)
try {
loading.value = true
if (type === 'public') {
publicPageNo.value++
await getPublicRole(true)
} else {
myPageNo.value++
await getMyRole(true)
}
} finally {
loading.value = false
}
}
/** 选择 card 角色:新建聊天对话 */
const handlerCardUse = async (role) => {
// 1.
const data: ChatConversationVO = {
roleId: role.id
} as unknown as ChatConversationVO
const conversationId = await ChatConversationApi.createChatConversationMy(data)
// 2.
// TODO @fan name~~~
await router.push({
path: `/ai/chat`,
query: {
conversationId: conversationId
}
})
}
/** 初始化 **/
onMounted(async () => {
//
await getRoleCategoryList()
// role
await getActiveTabsRole()
})
// TODO @fancss scss
</script>
<style lang="css">
.el-tabs__content {
position: relative;
height: 100%;
overflow: hidden;
}
.el-tabs__nav-scroll {
margin: 10px 20px;
}
</style>
<!-- 样式 -->
<style scoped lang="scss">
//
.role-container {
position: absolute;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: #ffffff;
overflow: hidden;
display: flex;
flex-direction: column;
.role-main {
flex: 1;
overflow: hidden;
margin: 0;
padding: 0;
position: relative;
.search-container {
margin: 20px 20px 0px 20px;
position: absolute;
right: 0;
top: -5px;
z-index: 100;
}
.search-input {
width: 240px;
}
.tabs {
position: relative;
height: 100%;
.role-category-list {
margin: 0 27px;
}
}
.role-pane {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
position: relative;
}
}
}
</style>

View File

@ -0,0 +1,145 @@
<template>
<el-drawer
v-model="showDrawer"
title="图片详细"
@close="handlerDrawerClose"
custom-class="drawer-class"
>
<!-- 图片 -->
<div class="item">
<!-- <div class="header">-->
<!-- <div>图片</div>-->
<!-- <div>-->
<!-- </div>-->
<!-- </div>-->
<div class="body">
<!-- TODO @fan: 要不这里只展示图片不用 ImageTaskCard -->
<ImageTaskCard :image-detail="imageDetail" />
</div>
</div>
<!-- 时间 -->
<div class="item">
<div class="tip">时间</div>
<div class="body">
<div>提交时间{{imageDetail.createTime}}</div>
<!-- TODO @fan要不加个完成时间的字段 finishTimeupdateTime 不算特别合理哈 -->
<div>生成时间{{imageDetail.updateTime}}</div>
</div>
</div>
<!-- 模型 -->
<div class="item">
<div class="tip">模型</div>
<div class="body">
{{imageDetail.model}}({{imageDetail.height}}x{{imageDetail.width}})
</div>
</div>
<!-- 提示词 -->
<div class="item">
<div class="tip">提示词</div>
<div class="body">
{{imageDetail.prompt}}
</div>
</div>
<!-- 地址 -->
<div class="item">
<div class="tip">图片地址</div>
<div class="body">
{{imageDetail.picUrl}}
</div>
</div>
<!-- 风格 -->
<div class="item" v-if="imageDetail?.options?.style">
<div class="tip">风格</div>
<div class="body">
<!-- TODO @fan貌似需要把 imageStyleList 搞到 api/image/index.ts 枚举起来 -->
<!-- TODO @fan这里的展示可能需要按照平台做区分 -->
{{imageDetail?.options?.style}}
</div>
</div>
</el-drawer>
</template>
<script setup lang="ts">
import {ImageApi, ImageDetailVO} from '@/api/ai/image';
import ImageTaskCard from './ImageTaskCard.vue';
const showDrawer = ref<boolean>(false) //
const imageDetail = ref<ImageDetailVO>({} as ImageDetailVO) //
const props = defineProps({
show: {
type: Boolean,
require: true,
default: false
},
id: {
type: Number,
required: true
}
})
/** 抽屉 - close */
const handlerDrawerClose = async () => {
emits('handlerDrawerClose')
}
/** 获取 - 图片 detail */
const getImageDetail = async (id) => {
//
imageDetail.value = await ImageApi.getImageDetail(id)
}
/** 任务 - detail */
const handlerTaskDetail = async () => {
showDrawer.value = true
}
// watch show
const { show } = toRefs(props)
watch(show, async (newValue, oldValue) => {
showDrawer.value = newValue as boolean
})
// watch id
const { id } = toRefs(props)
watch(id, async (newVal, oldVal) => {
if (newVal) {
await getImageDetail(newVal)
}
})
//
const emits = defineEmits(['handlerDrawerClose'])
//
onMounted(async () => {
})
</script>
<style scoped lang="scss">
.item {
margin-bottom: 20px;
width: 100%;
overflow: hidden;
word-wrap: break-word;
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.tip {
font-weight: bold;
font-size: 16px;
}
.body {
margin-top: 10px;
color: #616161;
.taskImage {
border-radius: 10px;
}
}
}
</style>

View File

@ -0,0 +1,210 @@
<template>
<el-card class="dr-task" body-class="task-card" shadow="never">
<template #header>绘画任务</template>
<div class="task-image-list" ref="imageTaskRef">
<ImageTaskCard
v-for="image in imageList"
:key="image"
:image-detail="image"
@on-btn-click="handlerImageBtnClick"
@on-mj-btn-click="handlerImageMjBtnClick"/>
</div>
<div class="task-image-pagination">
<el-pagination background layout="prev, pager, next"
:default-page-size="pageSize"
:total="pageTotal"
@change="handlerPageChange"
/>
</div>
</el-card>
<!-- 图片 detail 抽屉 -->
<ImageDetailDrawer
:show="isShowImageDetail"
:id="showImageDetailId"
@handler-drawer-close="handlerDrawerClose"
/>
</template>
<script setup lang="ts">
import {ImageApi, ImageDetailVO, ImageMjActionVO, ImageMjButtonsVO} from '@/api/ai/image';
import ImageDetailDrawer from './ImageDetailDrawer.vue'
import ImageTaskCard from './ImageTaskCard.vue'
import {ElLoading, LoadingOptionsResolved} from "element-plus";
const message = useMessage() //
const imageList = ref<ImageDetailVO[]>([]) // image
const imageListInterval = ref<any>() // image
const isShowImageDetail = ref<boolean>(false) // task
const showImageDetailId = ref<number>(0) // task
const imageTaskRef = ref<any>() // ref
const imageTaskLoadingInstance = ref<any>() // loading
const imageTaskLoading = ref<boolean>(false) // loading
const pageNo = ref<number>(1) // page no
const pageSize = ref<number>(10) // page size
const pageTotal = ref<number>(0) // page size
/** 抽屉 - close */
const handlerDrawerClose = async () => {
isShowImageDetail.value = false
}
/** 任务 - detail */
const handlerDrawerOpen = async () => {
isShowImageDetail.value = true
}
/**
* 获取 - image 列表
*/
const getImageList = async (apply:boolean = false) => {
imageTaskLoading.value = true
try {
imageTaskLoadingInstance.value = ElLoading.service({
target: imageTaskRef.value,
text: '加载中...'
} as LoadingOptionsResolved)
const { list, total } = await ImageApi.getImageList({pageNo: pageNo.value, pageSize: pageSize.value})
if (apply) {
imageList.value = [...imageList.value, ...list]
} else {
imageList.value = list
}
pageTotal.value = total
} finally {
if (imageTaskLoadingInstance.value) {
imageTaskLoadingInstance.value.close();
imageTaskLoadingInstance.value = null;
}
}
}
/** 图片 - btn click */
const handlerImageBtnClick = async (type, imageDetail: ImageDetailVO) => {
// image detail id
showImageDetailId.value = imageDetail.id
console.log('type', imageDetail.id)
// btn
if (type === 'more') {
await handlerDrawerOpen()
} else if (type === 'delete') {
await message.confirm(`是否删除照片?`)
await ImageApi.deleteImage(imageDetail.id)
await getImageList()
await message.success("删除成功!")
} else if (type === 'download') {
await downloadImage(imageDetail.picUrl)
}
}
/** 图片 - mj btn click */
const handlerImageMjBtnClick = async (button: ImageMjButtonsVO, imageDetail: ImageDetailVO) => {
// 1 params
const params = {
id: imageDetail.id,
customId: button.customId,
} as ImageMjActionVO
// 2 action
await ImageApi.midjourneyAction(params)
// 3
await getImageList()
}
/** 下载 - image */
// TODO @fan download
const downloadImage = async (imageUrl) => {
const image = new Image()
image.setAttribute('crossOrigin', 'anonymous')
image.src = imageUrl
image.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = image.width
canvas.height = image.height
const ctx = canvas.getContext('2d') as CanvasDrawImage
ctx.drawImage(image, 0, 0, image.width, image.height)
const url = canvas.toDataURL('image/png')
const a = document.createElement('a')
a.href = url
a.download = 'image.png'
a.click()
}
}
// page change
const handlerPageChange = async (page) => {
pageNo.value = page
await getImageList(false)
}
/** 暴露组件方法 */
defineExpose({getImageList})
/** 组件挂在的时候 */
onMounted(async () => {
// image
await getImageList()
// image
imageListInterval.value = setInterval(async () => {
await getImageList(false)
}, 1000 * 20)
})
/** 组件取消挂在的时候 */
onUnmounted(async () => {
if (imageListInterval.value) {
clearInterval(imageListInterval.value)
}
})
</script>
<style lang="scss">
.task-card {
margin: 0;
padding: 0;
height: 100%;
position: relative;
}
.task-image-list {
position: relative;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-content: flex-start;
height: 100%;
overflow: auto;
padding: 20px;
padding-bottom: 140px;
box-sizing: border-box; /* 确保内边距不会增加高度 */
>div {
margin-right: 20px;
margin-bottom: 20px;
}
>div:last-of-type {
//margin-bottom: 100px;
}
}
.task-image-pagination {
position: absolute;
bottom: 60px;
height: 50px;
line-height: 90px;
width: 100%;
z-index: 999;
background-color: #ffffff;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
</style>
<style scoped lang="scss">
.dr-task {
width: 100%;
height: 100%;
}
</style>

View File

@ -0,0 +1,135 @@
<template>
<el-card body-class="" class="image-card">
<div class="image-operation">
<div>
<el-button type="primary" text bg v-if="imageDetail?.status === 10">生成中</el-button>
<el-button text bg v-else-if="imageDetail?.status === 20">已完成</el-button>
<el-button type="danger" text bg v-else-if="imageDetail?.status === 30">异常</el-button>
</div>
<!-- TODO @fan1按钮要不调整成详情下载再次生成删除2如果是再次生成就把当前的参数填写到左侧的框框里 -->
<div>
<el-button class="btn" text :icon="Download"
@click="handlerBtnClick('download', imageDetail)"/>
<el-button class="btn" text :icon="Delete" @click="handlerBtnClick('delete', imageDetail)"/>
<el-button class="btn" text :icon="More" @click="handlerBtnClick('more', imageDetail)"/>
</div>
</div>
<div class="image-wrapper" ref="cardImageRef">
<!-- TODO @fan要不加个点击大图预览 -->
<img class="image" :src="imageDetail?.picUrl"/>
<div v-if="imageDetail?.status === 30">{{imageDetail?.errorMessage}}</div>
</div>
<!-- TODO @fanstyle 使用 unocss 替代下 -->
<div class="image-mj-btns">
<el-button size="small" v-for="button in imageDetail?.buttons" :key="button"
style="min-width: 40px;margin-left: 0; margin-right: 10px; margin-top: 5px;"
@click="handlerMjBtnClick(button)"
>
{{ button.label }}{{ button.emoji }}
</el-button>
</div>
</el-card>
</template>
<script setup lang="ts">
import {Delete, Download, More} from "@element-plus/icons-vue";
import {ImageDetailVO, ImageMjButtonsVO} from "@/api/ai/image";
import {PropType} from "vue";
import {ElLoading, ElMessageBox} from "element-plus";
const cardImageRef = ref<any>() // image ref
const cardImageLoadingInstance = ref<any>() // image ref
const message = useMessage()
const props = defineProps({
imageDetail: {
type: Object as PropType<ImageDetailVO>,
require: true
}
})
/** 按钮 - 点击事件 */
const handlerBtnClick = async (type, imageDetail: ImageDetailVO) => {
emits('onBtnClick', type, imageDetail)
}
const handlerLoading = async (status: number) => {
// TODO @fan Loading
if (status === 10) {
cardImageLoadingInstance.value = ElLoading.service({
target: cardImageRef.value,
text: '生成中...'
})
} else {
if (cardImageLoadingInstance.value) {
cardImageLoadingInstance.value.close();
cardImageLoadingInstance.value = null;
}
}
}
/** mj 按钮 click */
const handlerMjBtnClick = async (button: ImageMjButtonsVO) => {
//
await message.confirm(`确认操作 "${button.label} ${button.emoji}" ?`)
emits('onMjBtnClick', button, props.imageDetail)
}
// watch
const { imageDetail } = toRefs(props)
watch(imageDetail, async (newVal, oldVal) => {
await handlerLoading(newVal.status as string)
})
// emits
const emits = defineEmits(['onBtnClick', 'onMjBtnClick'])
//
onMounted(async () => {
await handlerLoading(props.imageDetail.status as string)
})
</script>
<style scoped lang="scss">
.image-card {
width: 320px;
height: auto;
border-radius: 10px;
position: relative;
display: flex;
flex-direction: column;
.image-operation {
display: flex;
flex-direction: row;
justify-content: space-between;
.btn {
//border: 1px solid red;
padding: 10px;
margin: 0;
}
}
.image-wrapper {
overflow: hidden;
margin-top: 20px;
height: 280px;
flex: 1;
.image {
width: 100%;
border-radius: 10px;
}
}
.image-mj-btns {
margin-top: 5px;
width: 100%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-start;
}
}
</style>

View File

@ -0,0 +1,398 @@
<!-- dall3 -->
<template>
<div class="prompt">
<el-text tag="b">画面描述</el-text>
<el-text tag="p">建议使用形容词+动词+风格的格式使用隔开</el-text>
<!-- TODO @fanstyle 看看能不能哟 unocss 替代 -->
<el-input
v-model="prompt"
maxlength="1024"
rows="5"
style="width: 100%; margin-top: 15px;"
input-style="border-radius: 7px;"
placeholder="例如:童话里的小屋应该是什么样子?"
show-word-limit
type="textarea"
/>
</div>
<div class="hot-words">
<div>
<el-text tag="b">随机热词</el-text>
</div>
<el-space wrap class="word-list">
<el-button round
class="btn"
:type="(selectHotWord === hotWord ? 'primary' : 'default')"
v-for="hotWord in hotWords"
:key="hotWord"
@click="handlerHotWordClick(hotWord)"
>
{{ hotWord }}
</el-button>
</el-space>
</div>
<div class="model">
<div>
<el-text tag="b">模型选择</el-text>
</div>
<el-space wrap class="model-list">
<div
:class="selectModel === model ? 'modal-item selectModel' : 'modal-item'"
v-for="model in models"
:key="model.key"
>
<el-image
:src="model.image"
fit="contain"
@click="handlerModelClick(model)"
/>
<div class="model-font">{{model.name}}</div>
</div>
</el-space>
</div>
<div class="image-style">
<div>
<el-text tag="b">风格选择</el-text>
</div>
<el-space wrap class="image-style-list">
<div
:class="selectImageStyle === imageStyle ? 'image-style-item selectImageStyle' : 'image-style-item'"
v-for="imageStyle in imageStyleList"
:key="imageStyle.key"
>
<el-image
:src="imageStyle.image"
fit="contain"
@click="handlerStyleClick(imageStyle)"
/>
<div class="style-font">{{imageStyle.name}}</div>
</div>
</el-space>
</div>
<div class="image-size">
<div>
<el-text tag="b">画面比例</el-text>
</div>
<el-space wrap class="size-list">
<div class="size-item"
v-for="imageSize in imageSizeList"
:key="imageSize.key"
@click="handlerSizeClick(imageSize)">
<div :class="selectImageSize === imageSize ? 'size-wrapper selectImageSize' : 'size-wrapper'">
<div :style="imageSize.style"></div>
</div>
<div class="size-font">{{ imageSize.name }}</div>
</div>
</el-space>
</div>
<div class="btns">
<el-button type="primary"
size="large"
round
:loading="drawIn"
@click="handlerGenerateImage">
{{drawIn ? '生成中' : '生成内容'}}
</el-button>
</div>
</template>
<script setup lang="ts">
import {ImageApi, ImageDrawReqVO} from '@/api/ai/image';
// image
interface ImageModelVO {
key: string
name: string
image: string
}
// image
interface ImageSizeVO {
key: string
name: string,
style: string,
width: string,
height: string,
}
//
const prompt = ref<string>('') //
const drawIn = ref<boolean>(false) //
const selectHotWord = ref<string>('') //
const hotWords = ref<string[]>(['中国旗袍', '古装美女', '卡通头像', '机甲战士', '童话小屋', '中国长城']) //
const selectModel = ref<any>({}) //
// message
const message = useMessage()
// TODO @fanimage
// TODO @fan image
const models = ref<ImageModelVO[]>([
{
key: 'dall-e-3',
name: 'DALL·E 3',
image: 'https://h5.cxyhub.com/images/model_2.png',
},
{
key: 'dall-e-2',
name: 'DALL·E 2',
image: 'https://h5.cxyhub.com/images/model_1.png',
},
]) //
selectModel.value = models.value[0]
const selectImageStyle = ref<any>({}) // style
// TODO @fanimage
const imageStyleList = ref<ImageModelVO[]>([
{
key: 'vivid',
name: '清晰',
image: 'https://h5.cxyhub.com/images/model_1.png',
},
{
key: 'natural',
name: '自然',
image: 'https://h5.cxyhub.com/images/model_2.png',
},
]) // style
selectImageStyle.value = imageStyleList.value[0]
const selectImageSize = ref<ImageSizeVO>({} as ImageSizeVO) // size
const imageSizeList = ref<ImageSizeVO[]>([
{
key: '1024x1024',
name: '1:1',
width: '1024',
height: '1024',
style: 'width: 30px; height: 30px;background-color: #dcdcdc;',
},
{
key: '1024x1792',
name: '3:5',
width: '1024',
height: '1792',
style: 'width: 30px; height: 50px;background-color: #dcdcdc;',
},
{
key: '1792x1024',
name: '5:3',
width: '1792',
height: '1024',
style: 'width: 50px; height: 30px;background-color: #dcdcdc;',
}
]) // size
selectImageSize.value = imageSizeList.value[0]
// Props
const props = defineProps({})
// emits
const emits = defineEmits(['onDrawStart', 'onDrawComplete'])
// TODO @fan /** */~
// TODO @fanhandler handle
/** 热词 - click */
const handlerHotWordClick = async (hotWord: string) => {
//
if (selectHotWord.value == hotWord) {
selectHotWord.value = ''
return
}
//
selectHotWord.value = hotWord
//
prompt.value = hotWord
}
/** 模型 - click */
const handlerModelClick = async (model: ImageModelVO) => {
if (selectModel.value === model) {
selectModel.value = {} as ImageModelVO
return
}
selectModel.value = model
}
/** 样式 - click */
const handlerStyleClick = async (imageStyle: ImageModelVO) => {
if (selectImageStyle.value === imageStyle) {
selectImageStyle.value = {} as ImageModelVO
return
}
selectImageStyle.value = imageStyle
}
/** size - click */
const handlerSizeClick = async (imageSize: ImageSizeVO) => {
if (selectImageSize.value === imageSize) {
selectImageSize.value = {} as ImageSizeVO
return
}
selectImageSize.value = imageSize
}
/** 图片生产 */
const handlerGenerateImage = async () => {
//
await message.confirm(`确认生成内容?`)
try {
//
drawIn.value = true
//
emits('onDrawStart', selectModel.value.key)
const form = {
platform: 'OpenAI',
prompt: prompt.value, //
model: selectModel.value.key, //
width: selectImageSize.value.width, // size
height: selectImageSize.value.height, // size
options: {
style: selectImageStyle.value.key, //
}
} as ImageDrawReqVO
//
await ImageApi.drawImage(form)
} finally {
//
emits('onDrawComplete', selectModel.value.key)
//
drawIn.value = false
}
}
</script>
<style scoped lang="scss">
//
.prompt {
}
//
.hot-words {
display: flex;
flex-direction: column;
margin-top: 30px;
.word-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: start;
margin-top: 15px;
.btn {
margin: 0;
}
}
}
//
.model {
margin-top: 30px;
.model-list {
margin-top: 15px;
.modal-item {
width: 110px;
//outline: 1px solid blue;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
border: 3px solid transparent;
cursor: pointer;
.model-font {
font-size: 14px;
color: #3e3e3e;
font-weight: bold;
}
}
.selectModel {
border: 3px solid #1293ff;
border-radius: 5px;
}
}
}
// style
.image-style {
margin-top: 30px;
.image-style-list {
margin-top: 15px;
.image-style-item {
width: 110px;
//outline: 1px solid blue;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
border: 3px solid transparent;
cursor: pointer;
.style-font {
font-size: 14px;
color: #3e3e3e;
font-weight: bold;
}
}
.selectImageStyle {
border: 3px solid #1293ff;
border-radius: 5px;
}
}
}
//
.image-size {
width: 100%;
margin-top: 30px;
.size-list {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
margin-top: 20px;
.size-item {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
.size-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 7px;
padding: 4px;
width: 50px;
height: 50px;
background-color: #fff;
border: 1px solid #fff;
}
.size-font {
font-size: 14px;
color: #3e3e3e;
font-weight: bold;
}
}
}
.selectImageSize {
border: 1px solid #1293ff !important;
}
}
.btns {
display: flex;
justify-content: center;
margin-top: 50px;
}
</style>

View File

@ -0,0 +1,105 @@
<!-- image -->
<template>
<div class="ai-image">
<div class="left">
<div class="segmented">
<el-segmented v-model="selectModel" :options="modelOptions" />
</div>
<div class="modal-switch-container">
<!-- TODO @fan1建议 Dall3 改成 OpenAI 绘图因为 dall3 其实本质是模型2涉及到中英文的地方中文和英文之间有个空格哈 -->
<Dall3 v-if="selectModel === 'DALL3绘画'"
@on-draw-start="handlerDrawStart"
@on-draw-complete="handlerDrawComplete" />
<Midjourney v-if="selectModel === 'MJ绘画'" />
<StableDiffusion v-if="selectModel === 'Stable Diffusion'" />
</div>
</div>
<div class="main">
<ImageTask ref="imageTaskRef" />
</div>
</div>
</template>
<script setup lang="ts">
// TODO @fan /views/ai/image/index /views/ai/image/manager
import Dall3 from './dall3/index.vue'
import Midjourney from './midjourney/index.vue'
import StableDiffusion from './stable-diffusion/index.vue'
import ImageTask from './ImageTask.vue'
// ref
const imageTaskRef = ref<any>() // image task ref
//
const selectModel = ref('Stable Diffusion')
const modelOptions = ['DALL3绘画', 'MJ绘画', 'Stable Diffusion']
const drawIn = ref<boolean>(false) //
/** 绘画 - start */
const handlerDrawStart = async (type) => {
// todo
drawIn.value = true
}
/** 绘画 - complete */
const handlerDrawComplete = async (type) => {
drawIn.value = false
// todo
await imageTaskRef.value.getImageList()
}
//
onMounted( async () => {
})
</script>
<style scoped lang="scss">
.ai-image {
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
display: flex;
flex-direction: row;
height: 100%;
width: 100%;
.left {
display: flex;
flex-direction: column;
padding: 20px;
width: 350px;
.segmented {
}
.segmented .el-segmented {
--el-border-radius-base: 16px;
--el-segmented-item-selected-color: #fff;
background-color: #ececec;
width: 350px;
}
.modal-switch-container {
height: 100%;
overflow-y: auto;
margin-top: 30px;
}
}
.main {
flex: 1;
background-color: #fff;
}
.right {
width: 350px;
background-color: #f7f8fa;
}
}
</style>

View File

@ -0,0 +1,388 @@
<!-- dall3 -->
<template>
<div class="prompt">
<el-text tag="b">画面描述</el-text>
<el-text tag="p">建议使用形容词+动词+风格的格式使用隔开.</el-text>
<el-input
v-model="prompt"
maxlength="1024"
rows="5"
style="width: 100%; margin-top: 15px;"
input-style="border-radius: 7px;"
placeholder="例如:童话里的小屋应该是什么样子?"
show-word-limit
type="textarea"
/>
</div>
<div class="hot-words">
<div>
<el-text tag="b">随机热词</el-text>
</div>
<el-space wrap class="word-list">
<el-button round
class="btn"
:type="(selectHotWord === hotWord ? 'primary' : 'default')"
v-for="hotWord in hotWords"
:key="hotWord"
@click="handlerHotWordClick(hotWord)"
>
{{ hotWord }}
</el-button>
</el-space>
</div>
<div class="image-size">
<div>
<el-text tag="b">尺寸</el-text>
</div>
<el-space wrap class="size-list">
<div class="size-item"
v-for="imageSize in imageSizeList"
:key="imageSize.key"
@click="handlerSizeClick(imageSize)">
<div :class="selectImageSize === imageSize ? 'size-wrapper selectImageSize' : 'size-wrapper'">
<div :style="imageSize.style"></div>
</div>
<div class="size-font">{{ imageSize.key }}</div>
</div>
</el-space>
</div>
<div class="version">
<div>
<el-text tag="b">版本</el-text>
</div>
<el-space wrap class="version-list">
<el-select
v-model="selectVersion"
class="version-select"
clearable
placeholder="请选择版本"
style="width: 350px"
@change="handlerChangeVersion"
>
<el-option
v-for="item in versionList"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-space>
</div>
<div class="model">
<div>
<el-text tag="b">模型</el-text>
</div>
<el-space wrap class="model-list">
<div
:class="selectModel === model ? 'modal-item selectModel' : 'modal-item'"
v-for="model in models"
:key="model.key"
>
<el-image
:src="model.image"
fit="contain"
@click="handlerModelClick(model)"
/>
<div class="model-font">{{model.name}}</div>
</div>
</el-space>
</div>
<div class="btns">
<!-- <el-button size="large" round>重置内容</el-button>-->
<el-button type="primary" size="large" round @click="handlerGenerateImage">生成内容</el-button>
</div>
</template>
<script setup lang="ts">
// image
import {ImageApi, ImageMidjourneyImagineReqVO} from "@/api/ai/image";
// message
const message = useMessage()
// emits
const emits = defineEmits(['onDrawStart', 'onDrawComplete'])
interface ImageModelVO {
key: string
name: string
image: string
}
// image
interface ImageSizeVO {
key: string
style: string,
width: string,
height: string,
}
//
const prompt = ref<string>('') //
const selectHotWord = ref<string>('') //
const hotWords = ref<string[]>(['中国旗袍', '古装美女', '卡通头像', '机甲战士', '童话小屋', '中国长城']) //
const selectModel = ref<any>() //
const models = ref<ImageModelVO[]>([
{
key: 'midjourney',
name: 'MJ',
image: 'https://bigpt8.com/pc/_nuxt/mj.34a61377.png',
},
{
key: 'niji',
name: 'NIJI',
image: 'https://bigpt8.com/pc/_nuxt/nj.ca79b143.png',
},
]) //
selectModel.value = models.value[0] //
const selectImageSize = ref<ImageSizeVO>({} as ImageSizeVO) // size
const imageSizeList = ref<ImageSizeVO[]>([
{
key: '1:1',
width: "1",
height: "1",
style: 'width: 30px; height: 30px;background-color: #dcdcdc;',
},
{
key: '3:4',
width: "3",
height: "4",
style: 'width: 30px; height: 40px;background-color: #dcdcdc;',
},
{
key: '4:3',
width: "4",
height: "3",
style: 'width: 40px; height: 30px;background-color: #dcdcdc;',
},
{
key: '9:16',
width: "9",
height: "16",
style: 'width: 30px; height: 50px;background-color: #dcdcdc;',
},
{
key: '16:9',
width: "16",
height: "9",
style: 'width: 50px; height: 30px;background-color: #dcdcdc;',
},
]) // size
selectImageSize.value = imageSizeList.value[0]
// version
let versionList = ref<any>([]) // version
const midjourneyVersionList = ref<any>([
{
value: '6.0',
label: 'v6.0',
},
{
value: '5.2',
label: 'v5.2',
},
{
value: '5.1',
label: 'v5.1',
},
{
value: '5.0',
label: 'v5.0',
},
{
value: '4.0',
label: 'v4.0',
},
])
const nijiVersionList = ref<any>([
{
value: '5',
label: 'v5',
},
])
const selectVersion = ref<any>('6.0') // version
versionList.value = midjourneyVersionList.value // midjourney
// Props
const props = defineProps({})
/** 热词 - click */
const handlerHotWordClick = async (hotWord: string) => {
//
if (selectHotWord.value == hotWord) {
selectHotWord.value = ''
return
}
//
selectHotWord.value = hotWord
//
prompt.value = hotWord
}
/** size - click */
const handlerSizeClick = async (imageSize: ImageSizeVO) => {
if (selectImageSize.value === imageSize) {
selectImageSize.value = {} as ImageSizeVO
return
}
selectImageSize.value = imageSize
}
/** 模型 - click */
const handlerModelClick = async (model: ImageModelVO) => {
selectModel.value = model
if (model.key === 'niji') {
versionList.value = nijiVersionList.value // niji
} else {
versionList.value = midjourneyVersionList.value // midjourney
}
selectVersion.value = versionList.value[0].value
}
/** version - click */
const handlerChangeVersion = async (version) => {
console.log('version', version)
}
/** 图片生产 */
const handlerGenerateImage = async () => {
//
await message.confirm(`确认生成内容?`)
// todo @
try {
//
emits('onDrawStart', selectModel.value.key)
//
const req = {
prompt: prompt.value,
model: selectModel.value.key,
width: selectImageSize.value.width,
height: selectImageSize.value.height,
version: selectVersion.value,
base64Array: [],
} as ImageMidjourneyImagineReqVO
await ImageApi.midjourneyImagine(req)
} finally {
//
emits('onDrawComplete', selectModel.value.key)
}
}
</script>
<style scoped lang="scss">
//
.prompt {
}
//
.hot-words {
display: flex;
flex-direction: column;
margin-top: 30px;
.word-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: start;
margin-top: 15px;
.btn {
margin: 0;
}
}
}
// version
.version {
margin-top: 20px;
.version-list {
margin-top: 20px;
width: 100%;
}
}
//
.model {
margin-top: 30px;
.model-list {
margin-top: 15px;
.modal-item {
display: flex;
flex-direction: column;
align-items: center;
width: 150px;
//outline: 1px solid blue;
overflow: hidden;
border: 3px solid transparent;
cursor: pointer;
.model-font {
font-size: 14px;
color: #3e3e3e;
font-weight: bold;
}
}
.selectModel {
border: 3px solid #1293ff;
border-radius: 5px;
}
}
}
//
.image-size {
width: 100%;
margin-top: 30px;
.size-list {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
margin-top: 20px;
.size-item {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
.size-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 7px;
padding: 4px;
width: 50px;
height: 50px;
background-color: #fff;
border: 1px solid #fff;
}
.size-font {
font-size: 14px;
color: #3e3e3e;
font-weight: bold;
}
}
}
.selectImageSize {
border: 1px solid #1293ff !important;
}
}
.btns {
display: flex;
justify-content: center;
margin-top: 50px;
}
</style>

View File

@ -0,0 +1,297 @@
<!-- dall3 -->
<template>
<div class="prompt">
<el-text tag="b">画面描述</el-text>
<el-text tag="p">建议使用形容词+动词+风格的格式使用隔开</el-text>
<!-- TODO @fanstyle 看看能不能哟 unocss 替代 -->
<el-input
v-model="prompt"
maxlength="1024"
rows="5"
style="width: 100%; margin-top: 15px;"
input-style="border-radius: 7px;"
placeholder="例如:童话里的小屋应该是什么样子?"
show-word-limit
type="textarea"
/>
</div>
<div class="hot-words">
<div>
<el-text tag="b">随机热词</el-text>
</div>
<el-space wrap class="word-list">
<el-button round
class="btn"
:type="(selectHotWord === hotWord ? 'primary' : 'default')"
v-for="hotWord in hotWords"
:key="hotWord"
@click="handlerHotWordClick(hotWord)"
>
{{ hotWord }}
</el-button>
</el-space>
</div>
<div class="group-item">
<div>
<el-text tag="b">采样</el-text>
</div>
<el-space wrap class="group-item-body">
<el-select
v-model="selectSampler"
placeholder="Select"
size="large"
style="width: 350px;"
>
<el-option
v-for="item in sampler"
:key="item.key"
:label="item.name"
:value="item.key"
/>
</el-select>
</el-space>
</div>
<div class="group-item">
<div>
<el-text tag="b">图片尺寸</el-text>
</div>
<el-space wrap class="group-item-body">
<el-select
v-model="selectImageSize"
placeholder="Select"
size="large"
style="width: 350px;"
>
<el-option
v-for="item in imageSizeList"
:key="item.key"
:label="item.key"
:value="item.key"
/>
</el-select>
</el-space>
</div>
<div class="group-item">
<div>
<el-text tag="b">迭代步数</el-text>
</div>
<el-space wrap class="group-item-body">
<el-input v-model="steps" type="number" size="large" style="width: 350px" placeholder="Please input" />
</el-space>
</div>
<div class="group-item">
<div>
<el-text tag="b">随机性</el-text>
</div>
<el-space wrap class="group-item-body">
<el-input v-model="seed" type="number" size="large" style="width: 350px" placeholder="Please input" />
</el-space>
</div>
<div class="btns">
<el-button type="primary"
size="large"
round
:loading="drawIn"
@click="handlerGenerateImage">
{{drawIn ? '生成中' : '生成内容'}}
</el-button>
</div>
</template>
<script setup lang="ts">
import {ImageApi, ImageDrawReqVO} from '@/api/ai/image';
// image
interface ImageModelVO {
key: string
name: string
}
// image
interface ImageSizeVO {
key: string
width: string,
height: string,
}
//
const prompt = ref<string>('') //
const drawIn = ref<boolean>(false) //
const selectHotWord = ref<string>('') //
const hotWords = ref<string[]>(['中国旗袍', '古装美女', '卡通头像', '机甲战士', '童话小屋', '中国长城']) //
const selectSampler = ref<any>({}) //
// message
const message = useMessage()
//
const sampler = ref<ImageModelVO[]>([
{
key: 'Euler a',
name: 'Euler a',
},
{
key: 'DPM++ 2S a Karras',
name: 'DPM++ 2S a Karras',
},
{
key: 'DPM++ 2M Karras',
name: 'DPM++ 2M Karras',
},
{
key: 'DPM++ SDE Karras',
name: 'DPM++ SDE Karras',
},
{
key: 'DPM++ 2M SDE Karras',
name: 'DPM++ 2M SDE Karras',
},
]) //
selectSampler.value = sampler.value[0]
const selectImageSize = ref<ImageSizeVO>({} as ImageSizeVO) // size
const imageSizeList = ref<ImageSizeVO[]>([
{
key: '512x512',
width: '512',
height: '512',
},
{
key: '1024x1024',
width: '1024',
height: '1024',
},
{
key: '1024x1792',
width: '1024',
height: '1792',
},
{
key: '1792x1024',
width: '1792',
height: '1024',
},
{
key: '2048x2048',
width: '2048',
height: '2048',
},
{
key: '720x1280',
width: '720',
height: '1280',
},
{
key: '1080x1920',
width: '1080',
height: '1920',
},
{
key: '1440x2560',
width: '1440',
height: '2560',
},
{
key: '2160x3840',
width: '2160',
height: '3840',
},
]) // size
selectImageSize.value = imageSizeList.value[0]
const steps = ref<number>(20) //
const seed = ref<number>(-1) //
// Props
const props = defineProps({})
// emits
const emits = defineEmits(['onDrawStart', 'onDrawComplete'])
// TODO @fan /** */~
// TODO @fanhandler handle
/** 热词 - click */
const handlerHotWordClick = async (hotWord: string) => {
//
if (selectHotWord.value == hotWord) {
selectHotWord.value = ''
return
}
//
selectHotWord.value = hotWord
//
prompt.value = hotWord
}
/** 图片生产 */
const handlerGenerateImage = async () => {
//
await message.confirm(`确认生成内容?`)
try {
//
drawIn.value = true
//
emits('onDrawStart', 'StableDiffusion')
const form = {
platform: 'StableDiffusion',
model: 'stable-diffusion-v1-6',
prompt: prompt.value, //
width: selectImageSize.value.width, //
height: selectImageSize.value.height, //
options: {
sampler: selectSampler.value.key, //
seed: seed.value, //
steps: steps.value, //
},
} as ImageDrawReqVO
//
await ImageApi.drawImage(form)
} finally {
//
emits('onDrawComplete', 'StableDiffusion')
//
drawIn.value = false
}
}
</script>
<style scoped lang="scss">
//
.prompt {
}
//
.hot-words {
display: flex;
flex-direction: column;
margin-top: 30px;
.word-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: start;
margin-top: 15px;
.btn {
margin: 0;
}
}
}
//
.group-item {
margin-top: 30px;
.group-item-body {
margin-top: 15px;
width: 100%;
}
}
.btns {
display: flex;
justify-content: center;
margin-top: 50px;
}
</style>

View File

@ -0,0 +1,132 @@
<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="名称" 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">
<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, DICT_TYPE, getStrDictOptions } from '@/utils/dict'
import { ApiKeyApi, ApiKeyVO } from '@/api/ai/model/apiKey'
import { CommonStatusEnum } from '@/utils/constants'
/** AI API 密钥 表单 */
defineOptions({ name: 'ApiKeyForm' })
const { t } = useI18n() //
const message = useMessage() //
const dialogVisible = ref(false) //
const dialogTitle = ref('') //
const formLoading = ref(false) // 12
const formType = ref('') // create - update -
const formData = ref({
id: undefined,
name: undefined,
apiKey: undefined,
platform: undefined,
url: undefined,
status: CommonStatusEnum.ENABLE
})
const formRules = reactive({
name: [{ required: true, message: '名称不能为空', trigger: 'blur' }],
apiKey: [{ required: true, message: '密钥不能为空', trigger: 'blur' }],
platform: [{ required: true, message: '平台不能为空', trigger: 'blur' }],
status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
})
const formRef = ref() // Ref
/** 打开弹窗 */
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 ApiKeyApi.getApiKey(id)
} finally {
formLoading.value = false
}
}
}
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 ApiKeyVO
if (formType.value === 'create') {
await ApiKeyApi.createApiKey(data)
message.success(t('common.createSuccess'))
} else {
await ApiKeyApi.updateApiKey(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
//
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
name: undefined,
apiKey: undefined,
platform: undefined,
url: undefined,
status: CommonStatusEnum.ENABLE
}
formRef.value?.resetFields()
}
</script>

View File

@ -0,0 +1,180 @@
<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="platform">
<el-select
v-model="queryParams.platform"
placeholder="请输入平台"
clearable
class="!w-240px"
>
<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="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px">
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
: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:api-key: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="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" />
</template>
</el-table-column>
<el-table-column label="操作" align="center">
<template #default="scope">
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['ai:api-key:update']"
>
编辑
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['ai:api-key: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>
<!-- 表单弹窗添加/修改 -->
<ApiKeyForm ref="formRef" @success="getList" />
</template>
<script setup lang="ts">
import { getIntDictOptions, DICT_TYPE, getStrDictOptions } from '@/utils/dict'
import { ApiKeyApi, ApiKeyVO } from '@/api/ai/model/apiKey'
import ApiKeyForm from './ApiKeyForm.vue'
/** AI API 密钥 列表 */
defineOptions({ name: 'AiApiKey' })
const message = useMessage() //
const { t } = useI18n() //
const loading = ref(true) //
const list = ref<ApiKeyVO[]>([]) //
const total = ref(0) //
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined,
platform: undefined,
status: undefined
})
const queryFormRef = ref() //
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await ApiKeyApi.getApiKeyPage(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 ApiKeyApi.deleteApiKey(id)
message.success(t('common.delSuccess'))
//
await getList()
} catch {}
}
/** 初始化 **/
onMounted(() => {
getList()
})
</script>

View File

@ -0,0 +1,181 @@
<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-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 { 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) // 12
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>

View File

@ -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>

View File

@ -0,0 +1,190 @@
<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="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) // 12
const formType = ref('') // create - update -
const formData = ref({
id: undefined,
modelId: undefined,
name: undefined,
avatar: undefined,
category: undefined,
sort: undefined,
description: undefined,
systemMessage: undefined,
publicStatus: true,
status: CommonStatusEnum.ENABLE
})
const formRules = ref() // reactive(formRulesObj)
const formRef = ref() // Ref
const chatModelList = ref([] as ChatModelVO[]) //
/** 是否【我】自己创建,私有角色 */
// TODO @fan computed
const isUser = (type: string) => {
return type === 'my-create' || type === 'my-update'
}
// TODO @fan使 formRules
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' }],
publicStatus: [{ required: true, message: '是否公开不能为空', trigger: 'blur' }]
}
formRules.value = reactive(formRulesObj)
}
/** 打开弹窗 */
// TODO @fantitle type title
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-createmy-update chat
// tip: createelse
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,
publicStatus: true,
status: CommonStatusEnum.ENABLE
}
formRef.value?.resetFields()
}
</script>

View File

@ -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>

View File

@ -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'
})
}

View File

@ -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'
})
}

View File

@ -17,7 +17,6 @@
:prop="item.prop"
align="center"
>
<!-- TODO @scholarIDEA 爆红的处理 -->
<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 @scholarIDEA
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'
})
}

View File

@ -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?.()

View File

@ -235,11 +235,7 @@ const handleChangeStatus = async (row: JobApi.JobVO) => {
message.success(text + '成功')
//
await getList()
} catch {
//
row.status =
row.status === InfraJobStatusEnum.NORMAL ? InfraJobStatusEnum.STOP : InfraJobStatusEnum.NORMAL
}
} catch {}
}
/** 删除按钮操作 */