mirror of
https://github.com/yudaocode/yudao-ui-admin-vue3.git
synced 2026-04-19 09:48:39 +00:00
Merge branch 'feature/iot-protocol' of https://gitee.com/yudaocode/yudao-ui-admin-vue3 into feature/iot-modbus
# Conflicts: # src/views/iot/device/device/detail/index.vue
This commit is contained in:
11
.env
11
.env
@@ -24,5 +24,14 @@ VITE_APP_DEFAULT_LOGIN_TENANT = 芋道源码
|
||||
VITE_APP_DEFAULT_LOGIN_USERNAME = admin
|
||||
VITE_APP_DEFAULT_LOGIN_PASSWORD = admin123
|
||||
|
||||
# API 加解密
|
||||
VITE_APP_API_ENCRYPT_ENABLE = true
|
||||
VITE_APP_API_ENCRYPT_HEADER = X-Api-Encrypt
|
||||
VITE_APP_API_ENCRYPT_ALGORITHM = AES
|
||||
VITE_APP_API_ENCRYPT_REQUEST_KEY = 52549111389893486934626385991395
|
||||
VITE_APP_API_ENCRYPT_RESPONSE_KEY = 96103715984234343991809655248883
|
||||
# VITE_APP_API_ENCRYPT_REQUEST_KEY = MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCls2rIpnGdYnLFgz1XU13GbNQ5DloyPpvW00FPGjqn5Z6JpK+kDtVlnkhwR87iRrE5Vf2WNqRX6vzbLSgveIQY8e8oqGCb829myjf1MuI+ZzN4ghf/7tEYhZJGPI9AbfxFqBUzm+kR3/HByAI22GLT96WM26QiMK8n3tIP/yiLswIDAQAB
|
||||
# VITE_APP_API_ENCRYPT_RESPONSE_KEY = MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAOH8IfIFxL/MR9XIg1UDv5z1fGXQI93E8wrU4iPFovL/sEt9uSgSkjyidC2O7N+m7EKtoN6b1u7cEwXSkwf3kfK0jdWLSQaNpX5YshqXCBzbDfugDaxuyYrNA4/tIMs7mzZAk0APuRXB35Dmupou7Yw7TFW/7QhQmGfzeEKULQvnAgMBAAECgYAw8LqlQGyQoPv5p3gRxEMOCfgL0JzD3XBJKztiRd35RDh40Nx1ejgjW4dPioFwGiVWd2W8cAGHLzALdcQT2KDJh+T/tsd4SPmI6uSBBK6Ff2DkO+kFFcuYvfclQQKqxma5CaZOSqhgenacmgTMFeg2eKlY3symV6JlFNu/IKU42QJBAOhxAK/Eq3e61aYQV2JSguhMR3b8NXJJRroRs/QHEanksJtl+M+2qhkC9nQVXBmBkndnkU/l2tYcHfSBlAyFySMCQQD445tgm/J2b6qMQmuUGQAYDN8FIkHjeKmha+l/fv0igWm8NDlBAem91lNDIPBUzHL1X1+pcts5bjmq99YdOnhtAkAg2J8dN3B3idpZDiQbC8fd5bGPmdI/pSUudAP27uzLEjr2qrE/QPPGdwm2m7IZFJtK7kK1hKio6u48t/bg0iL7AkEAuUUs94h+v702Fnym+jJ2CHEkXvz2US8UDs52nWrZYiM1o1y4tfSHm8H8bv8JCAa9GHyriEawfBraILOmllFdLQJAQSRZy4wmlaG48MhVXodB85X+VZ9krGXZ2TLhz7kz9iuToy53l9jTkESt6L5BfBDCVdIwcXLYgK+8KFdHN5W7HQ==
|
||||
|
||||
# 百度地图
|
||||
VITE_BAIDU_MAP_KEY = 'efHIw2qmH8RzHPxK0z0rbCgzDVLup9LD'
|
||||
VITE_BAIDU_MAP_KEY = 'Y2aJXiswwPxy6mwFs1z9c7U5gwX9WfUN'
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 40 KiB |
25
README.md
25
README.md
@@ -200,18 +200,19 @@
|
||||
|
||||
### 微信公众号
|
||||
|
||||
| | 功能 | 描述 |
|
||||
|-----|--------|-------------------------------|
|
||||
| 🚀 | 账号管理 | 配置接入的微信公众号,可支持多个公众号 |
|
||||
| 🚀 | 数据统计 | 统计公众号的用户增减、累计用户、消息概况、接口分析等数据 |
|
||||
| 🚀 | 粉丝管理 | 查看已关注、取关的粉丝列表,可对粉丝进行同步、打标签等操作 |
|
||||
| 🚀 | 消息管理 | 查看粉丝发送的消息列表,可主动回复粉丝消息 |
|
||||
| 🚀 | 自动回复 | 自动回复粉丝发送的消息,支持关注回复、消息回复、关键字回复 |
|
||||
| 🚀 | 标签管理 | 对公众号的标签进行创建、查询、修改、删除等操作 |
|
||||
| 🚀 | 菜单管理 | 自定义公众号的菜单,也可以从公众号同步菜单 |
|
||||
| 🚀 | 素材管理 | 管理公众号的图片、语音、视频等素材,支持在线播放语音、视频 |
|
||||
| 🚀 | 图文草稿箱 | 新增常用的图文素材到草稿箱,可发布到公众号 |
|
||||
| 🚀 | 图文发表记录 | 查看已发布成功的图文素材,支持删除操作 |
|
||||
| | 功能 | 描述 |
|
||||
|----|--------|-------------------------------|
|
||||
| 🚀 | 账号管理 | 配置接入的微信公众号,可支持多个公众号 |
|
||||
| 🚀 | 数据统计 | 统计公众号的用户增减、累计用户、消息概况、接口分析等数据 |
|
||||
| 🚀 | 粉丝管理 | 查看已关注、取关的粉丝列表,可对粉丝进行同步、打标签等操作 |
|
||||
| 🚀 | 消息管理 | 查看粉丝发送的消息列表,可主动回复粉丝消息 |
|
||||
| 🚀 | 模版消息 | 配置和发送模版消息,用于向粉丝推送通知类消息 |
|
||||
| 🚀 | 自动回复 | 自动回复粉丝发送的消息,支持关注回复、消息回复、关键字回复 |
|
||||
| 🚀 | 标签管理 | 对公众号的标签进行创建、查询、修改、删除等操作 |
|
||||
| 🚀 | 菜单管理 | 自定义公众号的菜单,也可以从公众号同步菜单 |
|
||||
| 🚀 | 素材管理 | 管理公众号的图片、语音、视频等素材,支持在线播放语音、视频 |
|
||||
| 🚀 | 图文草稿箱 | 新增常用的图文素材到草稿箱,可发布到公众号 |
|
||||
| 🚀 | 图文发表记录 | 查看已发布成功的图文素材,支持删除操作 |
|
||||
|
||||
### 商城系统
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ const include = [
|
||||
'echarts/components',
|
||||
'echarts/renderers',
|
||||
'echarts-wordcloud',
|
||||
'@wangeditor/editor',
|
||||
'@wangeditor/editor-for-vue',
|
||||
'@wangeditor-next/editor',
|
||||
'@wangeditor-next/editor-for-vue',
|
||||
'@microsoft/fetch-event-source',
|
||||
'markdown-it',
|
||||
'markmap-view',
|
||||
@@ -115,7 +115,8 @@ const include = [
|
||||
'@element-plus/icons-vue',
|
||||
'element-plus/es/components/footer/style/css',
|
||||
'element-plus/es/components/empty/style/css',
|
||||
'element-plus/es/components/mention/style/css'
|
||||
'element-plus/es/components/mention/style/css',
|
||||
'element-plus/es/components/progress/style/css'
|
||||
]
|
||||
|
||||
const exclude = ['@iconify/json']
|
||||
|
||||
13
package.json
13
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "yudao-ui-admin-vue3",
|
||||
"version": "2025.08-snapshot",
|
||||
"version": "2026.01-snapshot",
|
||||
"description": "基于vue3、vite4、element-plus、typesScript",
|
||||
"author": "xingyu",
|
||||
"private": false,
|
||||
@@ -25,15 +25,16 @@
|
||||
"lint:lint-staged": "lint-staged -c "
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.1.0",
|
||||
"@element-plus/icons-vue": "2.3.2",
|
||||
"@form-create/designer": "^3.2.6",
|
||||
"@form-create/element-ui": "^3.2.11",
|
||||
"@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",
|
||||
"@wangeditor/editor-for-vue": "^5.1.10",
|
||||
"@wangeditor-next/editor": "^5.6.46",
|
||||
"@wangeditor-next/editor-for-vue": "^5.1.14",
|
||||
"@wangeditor-next/plugin-mention": "^1.0.16",
|
||||
"@zxcvbn-ts/core": "^3.0.4",
|
||||
"animate.css": "^4.1.1",
|
||||
"axios": "1.9.0",
|
||||
@@ -47,7 +48,7 @@
|
||||
"driver.js": "^1.3.1",
|
||||
"echarts": "^5.5.0",
|
||||
"echarts-wordcloud": "^2.1.0",
|
||||
"element-plus": "2.9.1",
|
||||
"element-plus": "2.11.1",
|
||||
"fast-xml-parser": "^4.3.2",
|
||||
"highlight.js": "^11.9.0",
|
||||
"jsencrypt": "^3.3.2",
|
||||
@@ -65,6 +66,7 @@
|
||||
"pinia-plugin-persistedstate": "^3.2.1",
|
||||
"qrcode": "^1.5.3",
|
||||
"qs": "^6.12.0",
|
||||
"snabbdom": "^3.6.2",
|
||||
"sortablejs": "^1.15.3",
|
||||
"steady-xml": "^0.1.0",
|
||||
"url": "^0.11.3",
|
||||
@@ -74,6 +76,7 @@
|
||||
"vue-i18n": "9.10.2",
|
||||
"vue-router": "4.4.5",
|
||||
"vue-types": "^5.1.1",
|
||||
"vue3-print-nb": "^0.1.4",
|
||||
"vue3-signature": "^0.2.4",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"web-storage-cache": "^1.1.1",
|
||||
|
||||
6350
pnpm-lock.yaml
generated
6350
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,8 @@ export interface ChatMessageVO {
|
||||
model: number // 模型标志
|
||||
modelId: number // 模型编号
|
||||
content: string // 聊天内容
|
||||
reasoningContent?: string // 推理内容
|
||||
attachmentUrls?: string[] // 附件 URL 数组
|
||||
tokens: number // 消耗 Token 数量
|
||||
segmentIds?: number[] // 段落编号
|
||||
segments?: {
|
||||
@@ -21,6 +23,14 @@ export interface ChatMessageVO {
|
||||
documentId: number // 文档编号
|
||||
documentName: string // 文档名称
|
||||
}[]
|
||||
webSearchPages?: {
|
||||
name: string // 名称
|
||||
icon: string // 图标
|
||||
title: string // 标题
|
||||
url: string // URL
|
||||
snippet: string // 内容的简短描述
|
||||
summary: string // 内容的文本摘要
|
||||
}[]
|
||||
createTime: Date // 创建时间
|
||||
roleAvatar: string // 角色头像
|
||||
userAvatar: string // 用户头像
|
||||
@@ -42,9 +52,11 @@ export const ChatMessageApi = {
|
||||
content: string,
|
||||
ctrl,
|
||||
enableContext: boolean,
|
||||
enableWebSearch: boolean,
|
||||
onMessage,
|
||||
onError,
|
||||
onClose
|
||||
onClose,
|
||||
attachmentUrls?: string[]
|
||||
) => {
|
||||
const token = getAccessToken()
|
||||
return fetchEventSource(`${config.base_url}/ai/chat/message/send-stream`, {
|
||||
@@ -57,7 +69,9 @@ export const ChatMessageApi = {
|
||||
body: JSON.stringify({
|
||||
conversationId,
|
||||
content,
|
||||
useContext: enableContext
|
||||
useContext: enableContext,
|
||||
useSearch: enableWebSearch,
|
||||
attachmentUrls: attachmentUrls || []
|
||||
}),
|
||||
onmessage: onMessage,
|
||||
onerror: onError,
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ChatRoleVO {
|
||||
status: number // 状态
|
||||
knowledgeIds?: number[] // 引用的知识库 ID 列表
|
||||
toolIds?: number[] // 引用的工具 ID 列表
|
||||
mcpClientNames?: string[] // 引用的 MCP Client 名字列表
|
||||
}
|
||||
|
||||
// AI 聊天角色 分页请求 vo
|
||||
@@ -57,26 +58,26 @@ export const ChatRoleApi = {
|
||||
|
||||
// 获取 my role
|
||||
getMyPage: async (params: ChatRolePageReqVO) => {
|
||||
return await request.get({ url: `/ai/chat-role/my-page`, params})
|
||||
return await request.get({ url: `/ai/chat-role/my-page`, params })
|
||||
},
|
||||
|
||||
// 获取角色分类
|
||||
getCategoryList: async () => {
|
||||
return await request.get({ url: `/ai/chat-role/category-list`})
|
||||
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})
|
||||
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})
|
||||
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 })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export type ProcessDefinitionVO = {
|
||||
deploymentTIme: string
|
||||
suspensionState: number
|
||||
formType?: number
|
||||
formCustomCreatePath?: string
|
||||
}
|
||||
|
||||
export type ModelVO = {
|
||||
|
||||
@@ -108,3 +108,8 @@ export const getFormFieldsPermission = async (params: any) => {
|
||||
export const getProcessInstanceBpmnModelView = async (id: string) => {
|
||||
return await request.get({ url: '/bpm/process-instance/get-bpmn-model-view?id=' + id })
|
||||
}
|
||||
|
||||
// 获取流程实例打印数据
|
||||
export const getProcessInstancePrintData = async (id: string) => {
|
||||
return await request.get({ url: '/bpm/process-instance/get-print-data?processInstanceId=' + id })
|
||||
}
|
||||
|
||||
@@ -106,6 +106,11 @@ export const copyTask = async (data: any) => {
|
||||
return await request.put({ url: '/bpm/task/copy', data })
|
||||
}
|
||||
|
||||
// 撤回
|
||||
export const withdrawTask = async (taskId: string) => {
|
||||
return await request.put({ url: '/bpm/task/withdraw', params: { taskId } })
|
||||
}
|
||||
|
||||
// 获取我的待办任务
|
||||
export const myTodoTask = async (processInstanceId: string) => {
|
||||
return await request.get({ url: '/bpm/task/my-todo?processInstanceId=' + processInstanceId })
|
||||
|
||||
@@ -41,6 +41,6 @@ export const createFile = (data: any) => {
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
export const updateFile = (data: any) => {
|
||||
return request.upload({ url: '/infra/file/upload', data })
|
||||
export const updateFile = (data: any, onUploadProgress?: Function) => {
|
||||
return request.upload({ url: '/infra/file/upload', data, onUploadProgress })
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface FileClientConfig {
|
||||
accessKey?: string
|
||||
accessSecret?: string
|
||||
enablePathStyleAccess?: boolean
|
||||
enablePublicAccess?: boolean
|
||||
region?: string
|
||||
domain: string
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface DeviceVO {
|
||||
id: number // 设备 ID,主键,自增
|
||||
deviceName: string // 设备名称
|
||||
productId: number // 产品编号
|
||||
productName?: string // 产品名称(只有部分接口返回,例如 getDeviceLocationList)
|
||||
productKey: string // 产品标识
|
||||
deviceType: number // 设备类型
|
||||
nickname: string // 设备备注名称
|
||||
@@ -20,8 +21,6 @@ export interface DeviceVO {
|
||||
mqttClientId: string // MQTT 客户端 ID
|
||||
mqttUsername: string // MQTT 用户名
|
||||
mqttPassword: string // MQTT 密码
|
||||
authType: string // 认证类型
|
||||
locationType: number // 定位类型
|
||||
latitude?: number // 设备位置的纬度
|
||||
longitude?: number // 设备位置的经度
|
||||
areaId: number // 地区编码
|
||||
@@ -49,14 +48,6 @@ export interface IotDevicePropertyRespVO {
|
||||
updateTime: Date // 更新时间
|
||||
}
|
||||
|
||||
// TODO @芋艿:调整到 constants
|
||||
// IoT 设备状态枚举
|
||||
export enum DeviceStateEnum {
|
||||
INACTIVE = 0, // 未激活
|
||||
ONLINE = 1, // 在线
|
||||
OFFLINE = 2 // 离线
|
||||
}
|
||||
|
||||
// 设备认证参数 VO
|
||||
export interface IotDeviceAuthInfoVO {
|
||||
clientId: string // 客户端 ID
|
||||
@@ -123,6 +114,11 @@ export const DeviceApi = {
|
||||
return await request.get({ url: `/iot/device/simple-list?`, params: { deviceType, productId } })
|
||||
},
|
||||
|
||||
// 获取设备位置列表(用于地图展示)
|
||||
getDeviceLocationList: async () => {
|
||||
return await request.get<DeviceVO[]>({ url: `/iot/device/location-list` })
|
||||
},
|
||||
|
||||
// 根据产品编号,获取设备的精简信息列表
|
||||
getDeviceListByProductId: async (productId: number) => {
|
||||
return await request.get({ url: `/iot/device/simple-list?`, params: { productId } })
|
||||
@@ -161,5 +157,28 @@ export const DeviceApi = {
|
||||
// 发送设备消息
|
||||
sendDeviceMessage: async (params: IotDeviceMessageSendReqVO) => {
|
||||
return await request.post({ url: `/iot/device/message/send`, data: params })
|
||||
},
|
||||
|
||||
// 绑定子设备到网关
|
||||
bindDeviceGateway: async (data: { subIds: number[]; gatewayId: number }) => {
|
||||
return await request.put({ url: `/iot/device/bind-gateway`, data })
|
||||
},
|
||||
|
||||
// 解绑子设备与网关
|
||||
unbindDeviceGateway: async (data: { subIds: number[]; gatewayId: number }) => {
|
||||
return await request.put({ url: `/iot/device/unbind-gateway`, data })
|
||||
},
|
||||
|
||||
// 获取网关的子设备列表
|
||||
getSubDeviceList: async (gatewayId: number) => {
|
||||
return await request.get<DeviceVO[]>({
|
||||
url: `/iot/device/sub-device-list`,
|
||||
params: { gatewayId }
|
||||
})
|
||||
},
|
||||
|
||||
// 获取未绑定网关的子设备分页
|
||||
getUnboundSubDevicePage: async (params: any) => {
|
||||
return await request.get({ url: `/iot/device/unbound-sub-device-page`, params })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface ProductVO {
|
||||
id: number // 产品编号
|
||||
name: string // 产品名称
|
||||
productKey: string // 产品标识
|
||||
productSecret?: string // 产品密钥
|
||||
registerEnabled?: boolean // 动态注册
|
||||
protocolId: number // 协议编号
|
||||
categoryId: number // 产品所属品类标识符
|
||||
categoryName?: string // 产品所属品类名称
|
||||
@@ -13,9 +15,9 @@ export interface ProductVO {
|
||||
description: string // 产品描述
|
||||
status: number // 产品状态
|
||||
deviceType: number // 设备类型
|
||||
locationType: number // 设备类型
|
||||
netType: number // 联网方式
|
||||
codecType: string // 数据格式(编解码器类型)
|
||||
protocolType: string // 协议类型
|
||||
serializeType: string // 序列化类型
|
||||
deviceCount: number // 设备数量
|
||||
createTime: Date // 创建时间
|
||||
}
|
||||
@@ -26,15 +28,22 @@ export enum DeviceTypeEnum {
|
||||
GATEWAY_SUB = 1, // 网关子设备
|
||||
GATEWAY = 2 // 网关设备
|
||||
}
|
||||
// IOT 产品定位类型枚举类 0: 手动定位, 1: IP 定位, 2: 定位模块定位
|
||||
export enum LocationTypeEnum {
|
||||
IP = 1, // IP 定位
|
||||
MODULE = 2, // 设备定位
|
||||
MANUAL = 3 // 手动定位
|
||||
// IoT 协议类型枚举
|
||||
export enum ProtocolTypeEnum {
|
||||
TCP = 'tcp',
|
||||
UDP = 'udp',
|
||||
WEBSOCKET = 'websocket',
|
||||
HTTP = 'http',
|
||||
MQTT = 'mqtt',
|
||||
EMQX = 'emqx',
|
||||
COAP = 'coap',
|
||||
MODBUS_TCP = 'modbus_tcp'
|
||||
}
|
||||
// IOT 数据格式(编解码器类型)枚举类
|
||||
export enum CodecTypeEnum {
|
||||
ALINK = 'Alink' // 阿里云 Alink 协议
|
||||
|
||||
// IoT 序列化类型枚举
|
||||
export enum SerializeTypeEnum {
|
||||
JSON = 'json',
|
||||
BINARY = 'binary'
|
||||
}
|
||||
|
||||
// IoT 产品 API
|
||||
@@ -75,8 +84,8 @@ export const ProductApi = {
|
||||
},
|
||||
|
||||
// 查询产品(精简)列表
|
||||
getSimpleProductList() {
|
||||
return request.get({ url: '/iot/product/simple-list' })
|
||||
getSimpleProductList(deviceType?: number) {
|
||||
return request.get({ url: '/iot/product/simple-list', params: { deviceType } })
|
||||
},
|
||||
|
||||
// 根据 ProductKey 获取产品信息
|
||||
|
||||
@@ -16,18 +16,6 @@ export interface IotStatisticsSummaryRespVO {
|
||||
productCategoryDeviceCounts: Record<string, number>
|
||||
}
|
||||
|
||||
/** 时间戳-数值的键值对类型 */
|
||||
interface TimeValueItem {
|
||||
[key: string]: number
|
||||
}
|
||||
|
||||
/** IoT 消息统计数据类型 */
|
||||
export interface IotStatisticsDeviceMessageSummaryRespVO {
|
||||
statType: number
|
||||
upstreamCounts: TimeValueItem[]
|
||||
downstreamCounts: TimeValueItem[]
|
||||
}
|
||||
|
||||
/** 新的消息统计数据项 */
|
||||
export interface IotStatisticsDeviceMessageSummaryByDateRespVO {
|
||||
time: string
|
||||
@@ -41,6 +29,17 @@ export interface IotStatisticsDeviceMessageReqVO {
|
||||
times?: string[]
|
||||
}
|
||||
|
||||
/** 设备位置数据 VO */
|
||||
export interface DeviceLocationRespVO {
|
||||
id: number
|
||||
deviceName: string
|
||||
nickname?: string
|
||||
productName?: string
|
||||
state: number
|
||||
longitude: number
|
||||
latitude: number
|
||||
}
|
||||
|
||||
// IoT 数据统计 API
|
||||
export const StatisticsApi = {
|
||||
// 查询全局的数据统计
|
||||
|
||||
@@ -13,7 +13,13 @@ export interface SmsLoginVO {
|
||||
|
||||
// 登录
|
||||
export const login = (data: UserLoginVO) => {
|
||||
return request.post({ url: '/system/auth/login', data })
|
||||
return request.post({
|
||||
url: '/system/auth/login',
|
||||
data,
|
||||
headers: {
|
||||
isEncrypt: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 注册
|
||||
|
||||
49
src/api/mp/messageTemplate/index.ts
Normal file
49
src/api/mp/messageTemplate/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
// 消息模板 VO
|
||||
export interface MsgTemplateVO {
|
||||
id: number // 模版主键
|
||||
accountId: number // 公众号账号的编号
|
||||
appId: string // appId
|
||||
templateId: string // 公众号模板 ID
|
||||
title: string // 标题
|
||||
content: string // 模板内容
|
||||
example: string // 模板示例
|
||||
primaryIndustry: string // 模板所属行业的一级行业
|
||||
deputyIndustry: string // 模板所属行业的二级行业
|
||||
createTime: Date // 创建时间
|
||||
}
|
||||
|
||||
// 发送消息模板请求 VO
|
||||
export interface MsgTemplateSendVO {
|
||||
id: number // 模板编号
|
||||
userId: number // 用户编号
|
||||
data?: string // 模板数据(JSON 格式字符串)
|
||||
url?: string // 跳转链接
|
||||
miniProgramAppId?: string // 小程序 appId
|
||||
miniProgramPagePath?: string // 小程序页面路径
|
||||
miniprogram?: string // 小程序信息(JSON 格式字符串)
|
||||
}
|
||||
|
||||
// 公众号消息模板 API
|
||||
export const MessageTemplateApi = {
|
||||
// 查询消息模板分页
|
||||
getMessageTemplateList: async (params: any) => {
|
||||
return await request.get({ url: `/mp/message-template/list`, params })
|
||||
},
|
||||
|
||||
// 删除消息模板
|
||||
deleteMessageTemplate: async (id: number) => {
|
||||
return await request.delete({ url: `/mp/message-template/delete?id=` + id })
|
||||
},
|
||||
|
||||
// 同步公众号模板
|
||||
syncMessageTemplate: async (accountId: number) => {
|
||||
return await request.post({ url: `/mp/message-template/sync?accountId=` + accountId })
|
||||
},
|
||||
|
||||
// 发送消息模板
|
||||
sendMessageTemplate: async (data: MsgTemplateSendVO) => {
|
||||
return await request.post({ url: `/mp/message-template/send`, data })
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ export interface MailLogVO {
|
||||
id: number
|
||||
userId: number
|
||||
userType: number
|
||||
toMail: string
|
||||
toMails: string[]
|
||||
ccMails?: string[]
|
||||
bccMails?: string[]
|
||||
accountId: number
|
||||
fromMail: string
|
||||
templateId: number
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
export interface MailTemplateVO {
|
||||
id: number
|
||||
id?: number
|
||||
name: string
|
||||
code: string
|
||||
accountId: number
|
||||
nickname: string
|
||||
title: string
|
||||
content: string
|
||||
params: string
|
||||
status: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export interface MailSendReqVO {
|
||||
mail: string
|
||||
toMails: string[]
|
||||
ccMails?: string[]
|
||||
bccMails?: string[]
|
||||
templateCode: string
|
||||
templateParams: Map<String, Object>
|
||||
}
|
||||
@@ -46,7 +46,10 @@ export const deleteMailTemplate = async (id: number) => {
|
||||
|
||||
// 批量删除邮件模版
|
||||
export const deleteMailTemplateList = async (ids: number[]) => {
|
||||
return await request.delete({ url: '/system/mail-template/delete-list', params: { ids: ids.join(',') } })
|
||||
return await request.delete({
|
||||
url: '/system/mail-template/delete-list',
|
||||
params: { ids: ids.join(',') }
|
||||
})
|
||||
}
|
||||
|
||||
// 发送邮件
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface SocialClientVO {
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
agentId: string
|
||||
publicKey: string
|
||||
status: number
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface TenantVO {
|
||||
password: string
|
||||
expireTime: Date
|
||||
accountCount: number
|
||||
websites: string[]
|
||||
createTime: Date
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
:group="{ name: 'component', pull: 'clone', put: false }"
|
||||
:clone="handleCloneComponent"
|
||||
:animation="200"
|
||||
:force-fallback="true"
|
||||
:force-fallback="false"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<div>
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
<draggable
|
||||
v-model="pageComponents"
|
||||
:animation="200"
|
||||
:force-fallback="true"
|
||||
:force-fallback="false"
|
||||
class="page-prop-area drag-area"
|
||||
filter=".component-toolbar"
|
||||
ghost-class="draggable-ghost"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<el-text type="info" size="small"> 拖动左上角的小圆点可对其排序 </el-text>
|
||||
<VueDraggable
|
||||
:list="formData"
|
||||
:force-fallback="true"
|
||||
:force-fallback="false"
|
||||
:animation="200"
|
||||
handle=".drag-icon"
|
||||
class="m-t-8px"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Editor from './src/Editor.vue'
|
||||
import { IDomEditor } from '@wangeditor/editor'
|
||||
import { IDomEditor } from '@wangeditor-next/editor'
|
||||
|
||||
export interface EditorExpose {
|
||||
getEditorRef: () => Promise<IDomEditor>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import { PropType } from 'vue'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { i18nChangeLanguage, IDomEditor, IEditorConfig } from '@wangeditor/editor'
|
||||
import { Editor, Toolbar } from '@wangeditor-next/editor-for-vue'
|
||||
import { i18nChangeLanguage, IDomEditor, IEditorConfig } from '@wangeditor-next/editor'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { isNumber } from '@/utils/is'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useLocaleStore } from '@/store/modules/locale'
|
||||
import { getRefreshToken, getTenantId } from '@/utils/auth'
|
||||
import { getUploadUrl } from '@/components/UploadFile/src/useUpload'
|
||||
import { useUpload } from '@/components/UploadFile/src/useUpload'
|
||||
import merge from 'lodash-es/merge'
|
||||
|
||||
defineOptions({ name: 'Editor' })
|
||||
|
||||
@@ -20,18 +20,23 @@ const currentLocale = computed(() => localeStore.getCurrentLocale)
|
||||
i18nChangeLanguage(unref(currentLocale).lang)
|
||||
|
||||
const props = defineProps({
|
||||
editorId: propTypes.string.def('wangeEditor-1'),
|
||||
editorId: propTypes.string.def('wangEditor-1'),
|
||||
height: propTypes.oneOfType([Number, String]).def('500px'),
|
||||
editorConfig: {
|
||||
type: Object as PropType<Partial<IEditorConfig>>,
|
||||
default: () => undefined
|
||||
},
|
||||
readonly: propTypes.bool.def(false),
|
||||
modelValue: propTypes.string.def('')
|
||||
modelValue: propTypes.string.def(''),
|
||||
directory: propTypes.string.def('editor-default')
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change', 'update:modelValue'])
|
||||
|
||||
// 使用项目的上传方法,实现逐个文件上传
|
||||
const { httpRequest: imageHttpRequest } = useUpload(`${props.directory}-image`)
|
||||
const { httpRequest: videoHttpRequest } = useUpload(`${props.directory}-video`)
|
||||
|
||||
// 编辑器实例,必须用 shallowRef
|
||||
const editorRef = shallowRef<IDomEditor>()
|
||||
|
||||
@@ -40,6 +45,9 @@ const valueHtml = ref('')
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val: string) => {
|
||||
if (!val) {
|
||||
val = ''
|
||||
}
|
||||
if (val === unref(valueHtml)) return
|
||||
valueHtml.value = val
|
||||
},
|
||||
@@ -55,6 +63,20 @@ watch(
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
)
|
||||
watch(
|
||||
() => props.readonly,
|
||||
async (val) => {
|
||||
// 特殊:等待 editorRef 渲染完成
|
||||
if (!editorRef.value) {
|
||||
await nextTick()
|
||||
}
|
||||
if (val) {
|
||||
editorRef.value?.disable()
|
||||
} else {
|
||||
editorRef.value?.enable()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleCreated = (editor: IDomEditor) => {
|
||||
editorRef.value = editor
|
||||
@@ -62,7 +84,7 @@ const handleCreated = (editor: IDomEditor) => {
|
||||
|
||||
// 编辑器配置
|
||||
const editorConfig = computed((): IEditorConfig => {
|
||||
return Object.assign(
|
||||
return merge(
|
||||
{
|
||||
placeholder: '请输入内容...',
|
||||
readOnly: props.readonly,
|
||||
@@ -87,101 +109,63 @@ const editorConfig = computed((): IEditorConfig => {
|
||||
},
|
||||
autoFocus: false,
|
||||
scroll: true,
|
||||
EXTEND_CONF: {
|
||||
mentionConfig: {
|
||||
showModal: () => {},
|
||||
hideModal: () => {}
|
||||
}
|
||||
},
|
||||
MENU_CONF: {
|
||||
['uploadImage']: {
|
||||
server: getUploadUrl(),
|
||||
// 单个文件的最大体积限制,默认为 2M
|
||||
maxFileSize: 5 * 1024 * 1024,
|
||||
maxFileSize: 10 * 1024 * 1024,
|
||||
// 最多可上传几个文件,默认为 100
|
||||
maxNumberOfFiles: 10,
|
||||
maxNumberOfFiles: 100,
|
||||
// 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 []
|
||||
allowedFileTypes: ['image/*'],
|
||||
|
||||
// 自定义增加 http header
|
||||
headers: {
|
||||
Accept: '*',
|
||||
Authorization: 'Bearer ' + getRefreshToken(), // 使用 getRefreshToken() 方法,而不使用 getAccessToken() 方法的原因:Editor 无法方便的刷新访问令牌
|
||||
'tenant-id': getTenantId()
|
||||
},
|
||||
|
||||
// 超时时间,默认为 10 秒
|
||||
timeout: 15 * 1000, // 15 秒
|
||||
|
||||
// form-data fieldName,后端接口参数名称,默认值wangeditor-uploaded-image
|
||||
fieldName: 'file',
|
||||
|
||||
// 上传之前触发
|
||||
onBeforeUpload(file: File) {
|
||||
// console.log(file)
|
||||
return file
|
||||
},
|
||||
// 上传进度的回调函数
|
||||
onProgress(progress: number) {
|
||||
// progress 是 0-100 的数字
|
||||
console.log('progress', progress)
|
||||
},
|
||||
onSuccess(file: File, res: any) {
|
||||
console.log('onSuccess', file, res)
|
||||
},
|
||||
onFailed(file: File, res: any) {
|
||||
alert(res.message)
|
||||
console.log('onFailed', file, res)
|
||||
},
|
||||
onError(file: File, err: any, res: any) {
|
||||
alert(err.message)
|
||||
console.error('onError', file, err, res)
|
||||
},
|
||||
// 自定义插入图片
|
||||
customInsert(res: any, insertFn: InsertFnType) {
|
||||
insertFn(res.data, 'image', res.data)
|
||||
// 使用 customUpload 实现逐个文件上传,复用项目的 httpRequest
|
||||
async customUpload(file: File, insertFn: InsertFnType) {
|
||||
try {
|
||||
const res = await imageHttpRequest({
|
||||
file: file as any,
|
||||
onProgress: () => {},
|
||||
onSuccess: () => {},
|
||||
onError: () => {}
|
||||
} as any)
|
||||
// 兼容前端直连上传和后端上传两种模式的返回格式
|
||||
const url = (res as any).data?.data || (res as any).data
|
||||
insertFn(url, 'image', url)
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.msg || '图片上传失败')
|
||||
console.error('Upload error:', error)
|
||||
}
|
||||
}
|
||||
},
|
||||
['uploadVideo']: {
|
||||
server: getUploadUrl(),
|
||||
// 单个文件的最大体积限制,默认为 10M
|
||||
maxFileSize: 10 * 1024 * 1024,
|
||||
maxFileSize: 1024 * 1024 * 1024,
|
||||
// 最多可上传几个文件,默认为 100
|
||||
maxNumberOfFiles: 10,
|
||||
// 选择文件时的类型限制,默认为 ['video/*'] 。如不想限制,则设置为 []
|
||||
allowedFileTypes: ['video/*'],
|
||||
|
||||
// 自定义增加 http header
|
||||
headers: {
|
||||
Accept: '*',
|
||||
Authorization: 'Bearer ' + getRefreshToken(), // 使用 getRefreshToken() 方法,而不使用 getAccessToken() 方法的原因:Editor 无法方便的刷新访问令牌
|
||||
'tenant-id': getTenantId()
|
||||
},
|
||||
|
||||
// 超时时间,默认为 30 秒
|
||||
timeout: 15 * 1000, // 15 秒
|
||||
|
||||
// form-data fieldName,后端接口参数名称,默认值wangeditor-uploaded-image
|
||||
fieldName: 'file',
|
||||
|
||||
// 上传之前触发
|
||||
onBeforeUpload(file: File) {
|
||||
// console.log(file)
|
||||
return file
|
||||
},
|
||||
// 上传进度的回调函数
|
||||
onProgress(progress: number) {
|
||||
// progress 是 0-100 的数字
|
||||
console.log('progress', progress)
|
||||
},
|
||||
onSuccess(file: File, res: any) {
|
||||
console.log('onSuccess', file, res)
|
||||
},
|
||||
onFailed(file: File, res: any) {
|
||||
alert(res.message)
|
||||
console.log('onFailed', file, res)
|
||||
},
|
||||
onError(file: File, err: any, res: any) {
|
||||
alert(err.message)
|
||||
console.error('onError', file, err, res)
|
||||
},
|
||||
// 自定义插入图片
|
||||
customInsert(res: any, insertFn: InsertFnType) {
|
||||
insertFn(res.data, 'mp4', res.data)
|
||||
// 使用 customUpload 实现逐个文件上传,复用项目的 httpRequest
|
||||
async customUpload(file: File, insertFn: InsertFnType) {
|
||||
try {
|
||||
const res = await videoHttpRequest({
|
||||
file: file as any,
|
||||
onProgress: () => {},
|
||||
onSuccess: () => {},
|
||||
onError: () => {}
|
||||
} as any)
|
||||
// 兼容前端直连上传和后端上传两种模式的返回格式
|
||||
const url = (res as any).data?.data || (res as any).data
|
||||
insertFn(url, 'mp4', url)
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.msg || '视频上传失败')
|
||||
console.error('Upload error:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -240,4 +224,4 @@ defineExpose({
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style src="@wangeditor/editor/dist/css/style.css"></style>
|
||||
<style src="@wangeditor-next/editor/dist/css/style.css"></style>
|
||||
|
||||
196
src/components/FormCreate/src/components/DeptSelect.vue
Normal file
196
src/components/FormCreate/src/components/DeptSelect.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<!-- 部门选择器 - 树形结构显示 -->
|
||||
<template>
|
||||
<el-tree-select
|
||||
v-model="selectedValue"
|
||||
class="w-1/1"
|
||||
:data="deptTree"
|
||||
:props="treeProps"
|
||||
:multiple="multiple"
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholder || '请选择部门'"
|
||||
:check-strictly="true"
|
||||
:filterable="true"
|
||||
:filter-node-method="filterNode"
|
||||
:clearable="true"
|
||||
:render-after-expand="false"
|
||||
node-key="id"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { handleTree } from '@/utils/tree'
|
||||
import { getSimpleDeptList, type DeptVO } from '@/api/system/dept'
|
||||
import { useUserStoreWithOut } from '@/store/modules/user'
|
||||
|
||||
defineOptions({ name: 'DeptSelect' })
|
||||
|
||||
// 接受父组件参数
|
||||
interface Props {
|
||||
modelValue?: number | string | number[] | string[]
|
||||
multiple?: boolean
|
||||
returnType?: 'id' | 'name'
|
||||
defaultCurrentDept?: boolean
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
formCreateInject?: any
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
multiple: false,
|
||||
returnType: 'id',
|
||||
defaultCurrentDept: false,
|
||||
disabled: false,
|
||||
placeholder: ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: number | string | number[] | string[] | undefined): void
|
||||
}>()
|
||||
|
||||
// 树形选择器配置
|
||||
const treeProps = {
|
||||
label: 'name',
|
||||
value: 'id',
|
||||
children: 'children'
|
||||
}
|
||||
|
||||
// 部门树形数据
|
||||
const deptTree = ref<any[]>([])
|
||||
// 原始部门列表(用于 returnType='name' 时查找名称)
|
||||
const deptList = ref<DeptVO[]>([])
|
||||
// 当前选中值
|
||||
const selectedValue = ref<number | string | number[] | string[] | undefined>()
|
||||
|
||||
// 加载部门树形数据
|
||||
const loadDeptTree = async () => {
|
||||
try {
|
||||
const data = await getSimpleDeptList()
|
||||
deptList.value = data
|
||||
deptTree.value = handleTree(data)
|
||||
} catch (error) {
|
||||
console.warn('加载部门数据失败:', error)
|
||||
deptTree.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 根据 ID 获取部门名称
|
||||
const getDeptNameById = (id: number): string | undefined => {
|
||||
const dept = deptList.value.find((item) => item.id === id)
|
||||
return dept?.name
|
||||
}
|
||||
|
||||
// 根据名称获取部门 ID
|
||||
const getDeptIdByName = (name: string): number | undefined => {
|
||||
const dept = deptList.value.find((item) => item.name === name)
|
||||
return dept?.id
|
||||
}
|
||||
|
||||
// 处理选中值变化
|
||||
const handleChange = (value: number | number[] | undefined) => {
|
||||
if (value === undefined || value === null) {
|
||||
emit('update:modelValue', props.multiple ? [] : undefined)
|
||||
return
|
||||
}
|
||||
|
||||
// 根据 returnType 决定返回值类型
|
||||
if (props.returnType === 'name') {
|
||||
if (props.multiple && Array.isArray(value)) {
|
||||
const names = value.map((id) => getDeptNameById(id)).filter(Boolean) as string[]
|
||||
emit('update:modelValue', names)
|
||||
} else if (!props.multiple && typeof value === 'number') {
|
||||
const name = getDeptNameById(value)
|
||||
emit('update:modelValue', name)
|
||||
}
|
||||
} else {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
}
|
||||
|
||||
// 树节点过滤方法(支持搜索过滤)
|
||||
const filterNode = (value: string, data: any) => {
|
||||
if (!value) return true
|
||||
return data.name.includes(value)
|
||||
}
|
||||
|
||||
// 监听 modelValue 变化,同步到内部选中值
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (newValue === undefined || newValue === null) {
|
||||
selectedValue.value = props.multiple ? [] : undefined
|
||||
return
|
||||
}
|
||||
|
||||
// 如果 returnType 是 'name',需要将名称转换为 ID 用于树选择器显示
|
||||
if (props.returnType === 'name') {
|
||||
if (props.multiple && Array.isArray(newValue)) {
|
||||
const ids = (newValue as string[])
|
||||
.map((name) => getDeptIdByName(name))
|
||||
.filter(Boolean) as number[]
|
||||
selectedValue.value = ids
|
||||
} else if (!props.multiple && typeof newValue === 'string') {
|
||||
const id = getDeptIdByName(newValue)
|
||||
selectedValue.value = id
|
||||
}
|
||||
} else {
|
||||
selectedValue.value = newValue as number | number[]
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 检查是否有有效的预设值
|
||||
const hasValidPresetValue = (): boolean => {
|
||||
const value = props.modelValue
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return false
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 设置默认值(当前用户部门)
|
||||
const setDefaultValue = () => {
|
||||
console.log('[DeptSelect] setDefaultValue called, defaultCurrentDept:', props.defaultCurrentDept)
|
||||
|
||||
// 仅当 defaultCurrentDept 为 true 时处理
|
||||
if (!props.defaultCurrentDept) {
|
||||
console.log('[DeptSelect] defaultCurrentDept is false, skip')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已有预设值(预设值优先级高于默认当前部门)
|
||||
if (hasValidPresetValue()) {
|
||||
console.log('[DeptSelect] has preset value, skip:', props.modelValue)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前用户的部门 ID
|
||||
const userStore = useUserStoreWithOut()
|
||||
const user = userStore.getUser
|
||||
const deptId = user?.deptId
|
||||
|
||||
console.log('[DeptSelect] current user:', user, 'deptId:', deptId)
|
||||
|
||||
// 处理 deptId 为空或 0 的边界情况
|
||||
if (!deptId || deptId === 0) {
|
||||
console.log('[DeptSelect] deptId is invalid, skip')
|
||||
return
|
||||
}
|
||||
|
||||
// 根据多选模式决定默认值格式
|
||||
const defaultValue = props.multiple ? [deptId] : deptId
|
||||
console.log('[DeptSelect] setting default value:', defaultValue)
|
||||
emit('update:modelValue', defaultValue)
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据并设置默认值
|
||||
onMounted(async () => {
|
||||
await loadDeptTree()
|
||||
// 数据加载完成后设置默认值
|
||||
setDefaultValue()
|
||||
})
|
||||
</script>
|
||||
@@ -2,6 +2,7 @@ import request from '@/config/axios'
|
||||
import { isEmpty } from '@/utils/is'
|
||||
import { ApiSelectProps } from '@/components/FormCreate/src/type'
|
||||
import { jsonParse } from '@/utils'
|
||||
import { useUserStoreWithOut } from '@/store/modules/user'
|
||||
|
||||
export const useApiSelect = (option: ApiSelectProps) => {
|
||||
return defineComponent({
|
||||
@@ -56,13 +57,58 @@ export const useApiSelect = (option: ApiSelectProps) => {
|
||||
remoteField: {
|
||||
type: String,
|
||||
default: 'label'
|
||||
},
|
||||
// 返回值类型(用于部门选择器等):id 返回 ID,name 返回名称
|
||||
returnType: {
|
||||
type: String,
|
||||
default: 'id'
|
||||
},
|
||||
// 是否默认选中当前用户(仅 UserSelect 使用)
|
||||
defaultCurrentUser: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
setup(props, { emit }) {
|
||||
const attrs = useAttrs()
|
||||
const options = ref<any[]>([]) // 下拉数据
|
||||
const loading = ref(false) // 是否正在从远程获取数据
|
||||
const queryParam = ref<any>() // 当前输入的值
|
||||
|
||||
// 检查是否有有效的预设值
|
||||
const hasValidPresetValue = (): boolean => {
|
||||
const value = attrs.modelValue
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return false
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 设置默认当前用户(仅当 defaultCurrentUser 为 true 且无预设值时)
|
||||
const setDefaultCurrentUser = () => {
|
||||
// 仅当组件名为 UserSelect 且 defaultCurrentUser 为 true 时处理
|
||||
if (option.name !== 'UserSelect' || !props.defaultCurrentUser) {
|
||||
return
|
||||
}
|
||||
// 检查是否已有预设值(预设值优先级高于默认当前用户)
|
||||
if (hasValidPresetValue()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前用户 ID
|
||||
const userStore = useUserStoreWithOut()
|
||||
const user = userStore.getUser
|
||||
const currentUserId = user?.id
|
||||
if (currentUserId) {
|
||||
// 根据多选/单选模式设置默认值
|
||||
const defaultValue = props.multiple ? [currentUserId] : currentUserId
|
||||
emit('update:modelValue', defaultValue)
|
||||
}
|
||||
}
|
||||
|
||||
const getOptions = async () => {
|
||||
options.value = []
|
||||
// 接口选择器
|
||||
@@ -119,10 +165,21 @@ export const useApiSelect = (option: ApiSelectProps) => {
|
||||
|
||||
function parseOptions0(data: any[]) {
|
||||
if (Array.isArray(data)) {
|
||||
options.value = data.map((item: any) => ({
|
||||
label: parseExpression(item, props.labelField),
|
||||
value: parseExpression(item, props.valueField)
|
||||
}))
|
||||
options.value = data.map((item: any) => {
|
||||
const label = parseExpression(item, props.labelField)
|
||||
let value = parseExpression(item, props.valueField)
|
||||
|
||||
// 根据 returnType 决定返回值
|
||||
// 如果设置了 returnType 为 'name',则返回 label 作为 value
|
||||
if (props.returnType === 'name') {
|
||||
value = label
|
||||
}
|
||||
|
||||
return {
|
||||
label: label,
|
||||
value: value
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
console.warn(`接口[${props.url}] 返回结果不是一个数组`)
|
||||
@@ -172,6 +229,8 @@ export const useApiSelect = (option: ApiSelectProps) => {
|
||||
|
||||
onMounted(async () => {
|
||||
await getOptions()
|
||||
// 设置默认当前用户(在数据加载完成后)
|
||||
setDefaultCurrentUser()
|
||||
})
|
||||
|
||||
const buildSelect = () => {
|
||||
|
||||
@@ -19,13 +19,24 @@ export const useSelectRule = (option: SelectRuleOption) => {
|
||||
name,
|
||||
event: option.event,
|
||||
rule() {
|
||||
return {
|
||||
// 构建基础规则
|
||||
const baseRule: any = {
|
||||
type: name,
|
||||
field: generateUUID(),
|
||||
title: label,
|
||||
info: '',
|
||||
$required: false
|
||||
}
|
||||
// 将自定义 props 的默认值添加到 rule 的 props 中
|
||||
if (option.props && option.props.length > 0) {
|
||||
baseRule.props = {}
|
||||
option.props.forEach((prop: any) => {
|
||||
if (prop.field && prop.value !== undefined) {
|
||||
baseRule.props[prop.field] = prop.value
|
||||
}
|
||||
})
|
||||
}
|
||||
return baseRule
|
||||
},
|
||||
props(_, { t }) {
|
||||
if (!option.props) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Rule } from '@form-create/element-ui' //左侧拖拽按钮
|
||||
|
||||
// 左侧拖拽按钮
|
||||
export interface MenuItem {
|
||||
label: string
|
||||
@@ -14,24 +12,6 @@ export interface Menu {
|
||||
list: MenuItem[]
|
||||
}
|
||||
|
||||
export interface MenuList extends Array<Menu> {}
|
||||
|
||||
// 拖拽组件的规则
|
||||
export interface DragRule {
|
||||
icon: string
|
||||
name: string
|
||||
label: string
|
||||
children?: string
|
||||
inside?: true
|
||||
drag?: true | String
|
||||
dragBtn?: false
|
||||
mask?: false
|
||||
|
||||
rule(): Rule
|
||||
|
||||
props(v: any, v1: any): Rule[]
|
||||
}
|
||||
|
||||
// 通用下拉组件 Props 类型
|
||||
export interface ApiSelectProps {
|
||||
name: string // 组件名称
|
||||
@@ -46,6 +26,6 @@ export interface SelectRuleOption {
|
||||
label: string // label 名称
|
||||
name: string // 组件名称
|
||||
icon: string // 组件图标
|
||||
props?: any[], // 组件规则
|
||||
props?: any[] // 组件规则
|
||||
event?: any[] // 事件配置
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { Ref } from 'vue'
|
||||
import { Menu } from '@/components/FormCreate/src/type'
|
||||
import { apiSelectRule } from '@/components/FormCreate/src/config/selectRule'
|
||||
import { generateUUID } from '@/utils'
|
||||
|
||||
/**
|
||||
* 表单设计器增强 hook
|
||||
@@ -34,7 +35,7 @@ export const useFormCreateDesigner = async (designer: Ref) => {
|
||||
// 移除自带的上传组件规则,使用 uploadFileRule、uploadImgRule、uploadImgsRule 替代
|
||||
designer.value?.removeMenuItem('upload')
|
||||
// 移除自带的富文本组件规则,使用 editorRule 替代
|
||||
designer.value?.removeMenuItem('fc-editor')
|
||||
designer.value?.removeMenuItem('fcEditor')
|
||||
const components = [editorRule, uploadFileRule, uploadImgRule, uploadImgsRule]
|
||||
components.forEach((component) => {
|
||||
// 插入组件规则
|
||||
@@ -51,12 +52,38 @@ export const useFormCreateDesigner = async (designer: Ref) => {
|
||||
const userSelectRule = useSelectRule({
|
||||
name: 'UserSelect',
|
||||
label: '用户选择器',
|
||||
icon: 'icon-user-o'
|
||||
icon: 'icon-user-o',
|
||||
props: [
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'defaultCurrentUser',
|
||||
title: '默认选中当前用户',
|
||||
value: false
|
||||
}
|
||||
]
|
||||
})
|
||||
const deptSelectRule = useSelectRule({
|
||||
name: 'DeptSelect',
|
||||
label: '部门选择器',
|
||||
icon: 'icon-address-card-o'
|
||||
icon: 'icon-address-card-o',
|
||||
props: [
|
||||
{
|
||||
type: 'select',
|
||||
field: 'returnType',
|
||||
title: '返回值类型',
|
||||
value: 'id',
|
||||
options: [
|
||||
{ label: '部门编号', value: 'id' },
|
||||
{ label: '部门名称', value: 'name' }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'defaultCurrentDept',
|
||||
title: '默认选中当前部门',
|
||||
value: false
|
||||
}
|
||||
]
|
||||
})
|
||||
const dictSelectRule = useDictSelectRule()
|
||||
const apiSelectRule0 = useSelectRule({
|
||||
@@ -93,9 +120,60 @@ export const useFormCreateDesigner = async (designer: Ref) => {
|
||||
designer.value?.addMenu(menu)
|
||||
}
|
||||
|
||||
/**
|
||||
* 修复重复的字段 ID 问题
|
||||
* 当复制组件时,自动为新组件生成新的字段 ID
|
||||
*
|
||||
* 对应 issue:https://gitee.com/yudaocode/yudao-ui-admin-vue3/issues/ICM22X
|
||||
*/
|
||||
const fixDuplicateFields = () => {
|
||||
// 获取当前所有规则
|
||||
const rules = designer.value?.getRule() || []
|
||||
const fieldIds = new Set<string>()
|
||||
let hasChanges = false
|
||||
|
||||
// 遍历所有规则,检测并修复重复的字段 ID
|
||||
rules.forEach((rule: any) => {
|
||||
if (rule.field) {
|
||||
if (fieldIds.has(rule.field)) {
|
||||
// 发现重复,生成新的ID
|
||||
const oldField = rule.field
|
||||
const newField = generateUUID()
|
||||
console.log(`[FormCreate] 检测到重复字段ID: ${oldField}, 已自动更新为: ${newField}`)
|
||||
rule.field = newField
|
||||
hasChanges = true
|
||||
} else {
|
||||
fieldIds.add(rule.field)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 如果有重复字段被修复,更新设计器
|
||||
if (hasChanges) {
|
||||
designer.value?.setRule(rules)
|
||||
}
|
||||
|
||||
return hasChanges
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
buildFormComponents()
|
||||
buildSystemMenu()
|
||||
|
||||
// 监听设计器内容变化,自动修复重复字段ID
|
||||
let isFixing = false // 防止无限循环
|
||||
watch(
|
||||
() => designer.value?.getRule(),
|
||||
async () => {
|
||||
if (!isFixing) {
|
||||
isFixing = true
|
||||
await nextTick()
|
||||
fixDuplicateFields()
|
||||
isFixing = false
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
4
src/components/Map/index.ts
Normal file
4
src/components/Map/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import MapDialog from './src/MapDialog.vue'
|
||||
export { loadBaiduMapSdk } from './src/utils'
|
||||
|
||||
export { MapDialog }
|
||||
@@ -1,268 +0,0 @@
|
||||
<!-- 地图组件:基于百度地图GL实现 -->
|
||||
<!-- TODO @super:还存在两个没解决的小bug,一个是修改手动定位时一次加载 不知道为何定位点在地图左上角 调了半天没解决 第二个是检索地址确定定位的功能参照百度的文档没也搞好 回头再解决一下 -->
|
||||
<template>
|
||||
<div v-if="props.isWrite">
|
||||
<el-form ref="form" label-width="120px">
|
||||
<el-form-item label="定位位置:">
|
||||
<el-select
|
||||
class="w-full"
|
||||
v-model="state.address"
|
||||
clearable
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
placeholder="可输入地址查询经纬度"
|
||||
:remote-method="autoSearch"
|
||||
@change="handleAddressSelect"
|
||||
:loading="state.loading"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in state.mapAddrOptions"
|
||||
:key="item.value"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="设备地图:">
|
||||
<!-- TODO @super:这里看看 unocss 哈 -->
|
||||
<div id="bdMap" class="mapContainer"></div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-descriptions :column="2" border :labelStyle="{ 'font-weight': 'bold' }">
|
||||
<el-descriptions-item label="设备位置:">{{ state.address }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div id="bdMap" class="mapContainer"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, onMounted } from 'vue'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
|
||||
// 扩展 Window 接口以包含百度地图 GL API
|
||||
declare global {
|
||||
interface Window {
|
||||
BMapGL: any
|
||||
initBaiduMap: () => void
|
||||
}
|
||||
}
|
||||
|
||||
const emits = defineEmits(['locateChange', 'update:center'])
|
||||
const state = reactive({
|
||||
lonLat: '', // 经度,纬度
|
||||
address: '',
|
||||
loading: false,
|
||||
latitude: '', // 纬度
|
||||
longitude: '', // 经度
|
||||
map: null as any, // 地图对象
|
||||
mapAddrOptions: [] as any[],
|
||||
mapMarker: null as any, // 标记对象
|
||||
geocoder: null as any,
|
||||
autoComplete: null as any,
|
||||
tips: [] // 搜索提示
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
clickMap: propTypes.bool.def(false),
|
||||
isWrite: propTypes.bool.def(false),
|
||||
center: propTypes.string.def('')
|
||||
})
|
||||
|
||||
/** 加载百度地图 */
|
||||
const loadMap = () => {
|
||||
state.address = ''
|
||||
state.latitude = ''
|
||||
state.longitude = ''
|
||||
|
||||
// 创建百度地图 API 脚本,动态加载
|
||||
const script = document.createElement('script')
|
||||
script.src = `https://api.map.baidu.com/api?v=1.0&type=webgl&ak=${
|
||||
import.meta.env.VITE_BAIDU_MAP_KEY
|
||||
}&callback=initBaiduMap`
|
||||
document.body.appendChild(script)
|
||||
|
||||
// 定义全局回调函数
|
||||
window.initBaiduMap = () => {
|
||||
initMap()
|
||||
initGeocoder()
|
||||
initAutoComplete()
|
||||
|
||||
// TODO @super:这里加一行注释
|
||||
if (props.clickMap) {
|
||||
state.map.addEventListener('click', (e: any) => {
|
||||
console.log(e)
|
||||
const point = e.latlng
|
||||
console.log(point)
|
||||
state.lonLat = point.lng + ',' + point.lat
|
||||
console.log(state.lonLat)
|
||||
regeoCode(state.lonLat)
|
||||
})
|
||||
}
|
||||
|
||||
// TODO @super:这里加一行注释
|
||||
if (props.center) {
|
||||
regeoCode(props.center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化地图 */
|
||||
const initMap = () => {
|
||||
const mapId = 'bdMap'
|
||||
state.map = new window.BMapGL.Map(mapId)
|
||||
// TODO @super:这个是默认的哇?
|
||||
state.map.centerAndZoom(new window.BMapGL.Point(116.404, 39.915), 11)
|
||||
state.map.enableScrollWheelZoom()
|
||||
state.map.disableDoubleClickZoom()
|
||||
|
||||
// 添加地图控件
|
||||
state.map.addControl(new window.BMapGL.NavigationControl())
|
||||
state.map.addControl(new window.BMapGL.ScaleControl())
|
||||
state.map.addControl(new window.BMapGL.ZoomControl())
|
||||
}
|
||||
|
||||
/** 初始化地理编码器 */
|
||||
const initGeocoder = () => {
|
||||
state.geocoder = new window.BMapGL.Geocoder()
|
||||
}
|
||||
|
||||
/** 初始化自动完成 */
|
||||
const initAutoComplete = () => {
|
||||
state.autoComplete = new window.BMapGL.Autocomplete({
|
||||
input: 'searchInput',
|
||||
location: state.map
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索地址
|
||||
* @param queryValue 搜索关键词
|
||||
*/
|
||||
const autoSearch = (queryValue: string) => {
|
||||
if (!queryValue) {
|
||||
state.mapAddrOptions = []
|
||||
return
|
||||
}
|
||||
|
||||
state.loading = true
|
||||
|
||||
// 使用百度地图地点检索服务
|
||||
const localSearch = new window.BMapGL.LocalSearch(state.map, {
|
||||
onSearchComplete: (results: any) => {
|
||||
state.loading = false
|
||||
const temp: any[] = []
|
||||
|
||||
if (results && results.getPoi) {
|
||||
const pois = results.getPoi()
|
||||
pois.forEach((p: any) => {
|
||||
const point = p.point
|
||||
if (point && point.lng && point.lat) {
|
||||
temp.push({
|
||||
name: p.title,
|
||||
value: point.lng + ',' + point.lat
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
state.mapAddrOptions = temp
|
||||
}
|
||||
})
|
||||
|
||||
localSearch.search(queryValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理地址选择
|
||||
* @param value 选中的地址值
|
||||
*/
|
||||
const handleAddressSelect = (value: string) => {
|
||||
if (value) {
|
||||
regeoCode(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加标记点
|
||||
* @param lnglat 经纬度数组
|
||||
*/
|
||||
// TODO @super:拼写;尽量不要有 idea 绿色提醒哈
|
||||
const setMarker = (lnglat: any) => {
|
||||
if (!lnglat) return
|
||||
|
||||
// 如果点标记已存在则先移除原点
|
||||
if (state.mapMarker !== null) {
|
||||
state.map.removeOverlay(state.mapMarker)
|
||||
state.lonLat = ''
|
||||
}
|
||||
|
||||
// 创建新的标记点
|
||||
const point = new window.BMapGL.Point(lnglat[0], lnglat[1])
|
||||
state.mapMarker = new window.BMapGL.Marker(point)
|
||||
|
||||
// 添加点标记到地图
|
||||
state.map.addOverlay(state.mapMarker)
|
||||
state.map.centerAndZoom(point, 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* 经纬度转化为地址、添加标记点
|
||||
* @param lonLat 经度,纬度字符串
|
||||
*/
|
||||
// TODO @super:拼写;尽量不要有 idea 绿色提醒哈
|
||||
const regeoCode = (lonLat: string) => {
|
||||
if (!lonLat) return
|
||||
|
||||
// TODO @super:拼写;尽量不要有 idea 绿色提醒哈
|
||||
const lnglat = lonLat.split(',')
|
||||
if (lnglat.length !== 2) return
|
||||
|
||||
state.longitude = lnglat[0]
|
||||
state.latitude = lnglat[1]
|
||||
|
||||
// 通知父组件位置变更
|
||||
emits('locateChange', lnglat)
|
||||
emits('update:center', lonLat)
|
||||
|
||||
// 先将地图中心点设置到目标位置
|
||||
const point = new window.BMapGL.Point(lnglat[0], lnglat[1])
|
||||
state.map.centerAndZoom(point, 16)
|
||||
|
||||
// 再设置标记并获取地址
|
||||
setMarker(lnglat)
|
||||
getAddress(lnglat)
|
||||
}
|
||||
|
||||
// TODO @super:lnglat 拼写
|
||||
/**
|
||||
* 根据经纬度获取地址信息
|
||||
*
|
||||
* @param lnglat 经纬度数组
|
||||
*/
|
||||
const getAddress = (lnglat: any) => {
|
||||
const point = new window.BMapGL.Point(lnglat[0], lnglat[1])
|
||||
|
||||
state.geocoder.getLocation(point, (result: any) => {
|
||||
if (result && result.address) {
|
||||
state.address = result.address
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 显式暴露方法,使其可以被父组件访问 */
|
||||
defineExpose({ regeoCode })
|
||||
|
||||
onMounted(() => {
|
||||
loadMap()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mapContainer {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
}
|
||||
</style>
|
||||
259
src/components/Map/src/MapDialog.vue
Normal file
259
src/components/Map/src/MapDialog.vue
Normal file
@@ -0,0 +1,259 @@
|
||||
<!-- 地图选择弹窗组件:基于百度地图 GL 实现 -->
|
||||
<template>
|
||||
<Dialog
|
||||
title="百度地图"
|
||||
v-model="dialogVisible"
|
||||
@opened="handleDialogOpened"
|
||||
@closed="handleDialogClosed"
|
||||
>
|
||||
<div class="w-full">
|
||||
<!-- 第一行:位置搜索 -->
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="定位位置">
|
||||
<el-select
|
||||
class="w-full"
|
||||
v-model="state.address"
|
||||
clearable
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
placeholder="可输入地址查询经纬度"
|
||||
:remote-method="autoSearch"
|
||||
@change="handleAddressSelect"
|
||||
:loading="state.loading"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in state.mapAddressOptions"
|
||||
:key="item.value"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<!-- 第二行:坐标显示 -->
|
||||
<el-form-item label="当前坐标">
|
||||
<div class="flex items-center gap-4">
|
||||
<span>经度: {{ state.longitude || '-' }}</span>
|
||||
<span>纬度: {{ state.latitude || '-' }}</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<!-- 第三行:地图 -->
|
||||
<div
|
||||
v-if="state.mapContainerReady"
|
||||
ref="mapContainerRef"
|
||||
class="w-full h-[400px] mt-[10px]"
|
||||
></div>
|
||||
<div v-else class="w-full h-[400px] mt-[10px] flex items-center justify-center">
|
||||
<span class="text-gray-400">地图加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="handleConfirm" type="primary">确 定</el-button>
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, nextTick } from 'vue'
|
||||
import { loadBaiduMapSdk } from './utils'
|
||||
|
||||
const emits = defineEmits(['confirm'])
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const mapContainerRef = ref<HTMLElement>()
|
||||
const state = reactive({
|
||||
lonLat: '', // 经纬度字符串,格式为 "经度,纬度"
|
||||
address: '', // 地址信息
|
||||
loading: false, // 地址搜索加载状态
|
||||
latitude: '', // 纬度
|
||||
longitude: '', // 经度
|
||||
map: null as any, // 百度地图实例
|
||||
mapAddressOptions: [] as any[], // 地址搜索选项
|
||||
mapMarker: null as any, // 地图标记点
|
||||
geocoder: null as any, // 地理编码器实例
|
||||
mapContainerReady: false // 地图容器是否准备好
|
||||
})
|
||||
|
||||
// 初始经纬度(打开弹窗时传入)
|
||||
const initLongitude = ref<number | undefined>()
|
||||
const initLatitude = ref<number | undefined>()
|
||||
|
||||
/** 打开弹窗 */
|
||||
const open = (longitude?: number, latitude?: number) => {
|
||||
initLongitude.value = longitude
|
||||
initLatitude.value = latitude
|
||||
state.longitude = longitude ? String(longitude) : ''
|
||||
state.latitude = latitude ? String(latitude) : ''
|
||||
state.address = ''
|
||||
state.mapAddressOptions = []
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
|
||||
/** 弹窗打开动画完成后初始化地图 */
|
||||
const handleDialogOpened = async () => {
|
||||
// 先显示地图容器
|
||||
state.mapContainerReady = true
|
||||
|
||||
// 等待下一个 DOM 更新周期,确保地图容器已渲染
|
||||
await nextTick()
|
||||
// 加载百度地图 SDK
|
||||
await loadBaiduMapSdk()
|
||||
initMapInstance()
|
||||
}
|
||||
|
||||
/** 弹窗关闭后清理地图 */
|
||||
const handleDialogClosed = () => {
|
||||
// 销毁地图实例
|
||||
if (state.map) {
|
||||
state.map.destroy?.()
|
||||
state.map = null
|
||||
}
|
||||
state.mapMarker = null
|
||||
state.geocoder = null
|
||||
state.mapContainerReady = false
|
||||
}
|
||||
|
||||
/** 初始化地图实例 */
|
||||
const initMapInstance = () => {
|
||||
if (!mapContainerRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化地图和地理编码器
|
||||
initMap()
|
||||
initGeocoder()
|
||||
|
||||
// 监听地图点击事件
|
||||
state.map.addEventListener('click', (e: any) => {
|
||||
const point = e.latlng
|
||||
state.lonLat = point.lng + ',' + point.lat
|
||||
regeoCode(state.lonLat)
|
||||
})
|
||||
|
||||
// 如果有初始经纬度,加载标记点
|
||||
if (initLongitude.value && initLatitude.value) {
|
||||
const lonLat = `${initLongitude.value},${initLatitude.value}`
|
||||
regeoCode(lonLat)
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化地图 */
|
||||
const initMap = () => {
|
||||
state.map = new window.BMapGL.Map(mapContainerRef.value)
|
||||
state.map.centerAndZoom(new window.BMapGL.Point(116.404, 39.915), 11)
|
||||
state.map.enableScrollWheelZoom()
|
||||
state.map.disableDoubleClickZoom()
|
||||
|
||||
state.map.addControl(new window.BMapGL.NavigationControl())
|
||||
state.map.addControl(new window.BMapGL.ScaleControl())
|
||||
state.map.addControl(new window.BMapGL.ZoomControl())
|
||||
}
|
||||
|
||||
/** 初始化地理编码器 */
|
||||
const initGeocoder = () => {
|
||||
state.geocoder = new window.BMapGL.Geocoder()
|
||||
}
|
||||
|
||||
/** 搜索地址 */
|
||||
const autoSearch = (queryValue: string) => {
|
||||
if (!queryValue) {
|
||||
state.mapAddressOptions = []
|
||||
return
|
||||
}
|
||||
|
||||
state.loading = true
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
const localSearch = new window.BMapGL.LocalSearch(state.map, {
|
||||
onSearchComplete: (results: any) => {
|
||||
state.loading = false
|
||||
const temp: any[] = []
|
||||
|
||||
if (results && results._pois) {
|
||||
results._pois.forEach((p: any) => {
|
||||
const point = p.point
|
||||
if (point && point.lng && point.lat) {
|
||||
temp.push({
|
||||
name: p.title,
|
||||
value: point.lng + ',' + point.lat
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
state.mapAddressOptions = temp
|
||||
}
|
||||
})
|
||||
|
||||
localSearch.search(queryValue)
|
||||
}
|
||||
|
||||
/** 处理地址选择 */
|
||||
const handleAddressSelect = (value: string) => {
|
||||
if (value) {
|
||||
regeoCode(value)
|
||||
}
|
||||
}
|
||||
|
||||
/** 添加标记点 */
|
||||
const setMarker = (lnglat: string[]) => {
|
||||
if (!lnglat) {
|
||||
return
|
||||
}
|
||||
|
||||
if (state.mapMarker !== null) {
|
||||
state.map.removeOverlay(state.mapMarker)
|
||||
}
|
||||
|
||||
const point = new window.BMapGL.Point(lnglat[0], lnglat[1])
|
||||
state.mapMarker = new window.BMapGL.Marker(point)
|
||||
|
||||
state.map.addOverlay(state.mapMarker)
|
||||
state.map.centerAndZoom(point, 16)
|
||||
}
|
||||
|
||||
/** 经纬度转地址、添加标记点 */
|
||||
const regeoCode = (lonLat: string) => {
|
||||
if (!lonLat) {
|
||||
return
|
||||
}
|
||||
const lnglat = lonLat.split(',')
|
||||
if (lnglat.length !== 2) {
|
||||
return
|
||||
}
|
||||
|
||||
state.longitude = lnglat[0]
|
||||
state.latitude = lnglat[1]
|
||||
const point = new window.BMapGL.Point(lnglat[0], lnglat[1])
|
||||
state.map.centerAndZoom(point, 16)
|
||||
|
||||
setMarker(lnglat)
|
||||
getAddress(lnglat)
|
||||
}
|
||||
|
||||
/** 根据经纬度获取地址信息 */
|
||||
const getAddress = (lnglat: string[]) => {
|
||||
const point = new window.BMapGL.Point(lnglat[0], lnglat[1])
|
||||
state.geocoder.getLocation(point, (result: any) => {
|
||||
if (result && result.address) {
|
||||
state.address = result.address
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 确认选择 */
|
||||
const handleConfirm = () => {
|
||||
if (state.longitude && state.latitude) {
|
||||
emits('confirm', {
|
||||
longitude: state.longitude,
|
||||
latitude: state.latitude,
|
||||
address: state.address
|
||||
})
|
||||
}
|
||||
dialogVisible.value = false
|
||||
}
|
||||
</script>
|
||||
62
src/components/Map/src/utils.ts
Normal file
62
src/components/Map/src/utils.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 百度地图 SDK 加载工具
|
||||
*/
|
||||
|
||||
// 扩展 Window 接口以包含百度地图 GL API
|
||||
declare global {
|
||||
interface Window {
|
||||
BMapGL: any
|
||||
}
|
||||
}
|
||||
|
||||
// 全局回调名称
|
||||
const CALLBACK_NAME = '__BAIDU_MAP_LOAD_CALLBACK__'
|
||||
|
||||
// SDK 加载状态
|
||||
let loadPromise: Promise<void> | null = null
|
||||
|
||||
/**
|
||||
* 加载百度地图 GL SDK
|
||||
* @param timeout 超时时间(毫秒),默认 10000
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export const loadBaiduMapSdk = (timeout = 10000): Promise<void> => {
|
||||
// 已加载完成
|
||||
if (window.BMapGL) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// 正在加载中,返回同一个 Promise
|
||||
if (loadPromise) {
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
loadPromise = new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
loadPromise = null
|
||||
reject(new Error('百度地图 SDK 加载超时'))
|
||||
}, timeout)
|
||||
|
||||
// 全局回调
|
||||
;(window as any)[CALLBACK_NAME] = () => {
|
||||
clearTimeout(timeoutId)
|
||||
delete (window as any)[CALLBACK_NAME]
|
||||
resolve()
|
||||
}
|
||||
|
||||
// 创建 script 标签
|
||||
const script = document.createElement('script')
|
||||
script.src = `https://api.map.baidu.com/api?v=1.0&type=webgl&ak=${
|
||||
import.meta.env.VITE_BAIDU_MAP_KEY
|
||||
}&callback=${CALLBACK_NAME}`
|
||||
script.onerror = () => {
|
||||
clearTimeout(timeoutId)
|
||||
loadPromise = null
|
||||
delete (window as any)[CALLBACK_NAME]
|
||||
reject(new Error('百度地图 SDK 加载失败'))
|
||||
}
|
||||
document.body.appendChild(script)
|
||||
})
|
||||
|
||||
return loadPromise
|
||||
}
|
||||
@@ -17,7 +17,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { copy } = useClipboard() // 初始化 copy 到粘贴板
|
||||
const { copy } = useClipboard({ legacy: true }) // 初始化 copy 到粘贴板
|
||||
const contentRef = ref()
|
||||
|
||||
const md = new MarkdownIt({
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</el-select>
|
||||
</ElDialog>
|
||||
<div v-else class="custom-hover" @click.stop="showTopSearch = !showTopSearch">
|
||||
<Icon icon="ep:search" />
|
||||
<Icon icon="ep:search" :color="color"/>
|
||||
<el-select
|
||||
@click.stop
|
||||
filterable
|
||||
@@ -41,11 +41,13 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
defineProps({
|
||||
isModal: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
color: propTypes.string.def('')
|
||||
})
|
||||
|
||||
const router = useRouter() // 路由对象
|
||||
|
||||
@@ -236,7 +236,7 @@
|
||||
<el-divider />
|
||||
<div>
|
||||
<el-button type="primary" @click="saveConfig">确 定</el-button>
|
||||
<el-button @click="closeDrawer">取 消</el-button>
|
||||
<el-button @click="cancelConfig">取 消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
@@ -467,6 +467,13 @@ const saveConfig = async () => {
|
||||
return true
|
||||
}
|
||||
|
||||
/** 取消配置 */
|
||||
const cancelConfig = () => {
|
||||
// 恢复原来的配置
|
||||
currentNode.value.triggerSetting = originalSetting
|
||||
closeDrawer()
|
||||
}
|
||||
|
||||
/** 获取节点展示内容 */
|
||||
const getShowText = (): string => {
|
||||
let showText = ''
|
||||
@@ -498,7 +505,7 @@ const getShowText = (): string => {
|
||||
/** 显示触发器节点配置, 由父组件传过来 */
|
||||
const showTriggerNodeConfig = (node: SimpleFlowNode) => {
|
||||
nodeName.value = node.name
|
||||
originalSetting = node.triggerSetting ? JSON.parse(JSON.stringify(node.triggerSetting)) : {}
|
||||
originalSetting = cloneDeep(node.triggerSetting)
|
||||
if (node.triggerSetting) {
|
||||
configForm.value = {
|
||||
type: node.triggerSetting.type,
|
||||
|
||||
@@ -212,7 +212,6 @@
|
||||
transform-origin: 50% 0 0;
|
||||
min-width: fit-content;
|
||||
transform: scale(1);
|
||||
transition: transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
background: url(@/assets/svgs/bpm/simple-process-bg.svg) 0 0 repeat;
|
||||
// 节点容器 定义节点宽度
|
||||
.node-container {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import * as FileApi from '@/api/infra/file'
|
||||
// import CryptoJS from 'crypto-js'
|
||||
import { UploadRawFile, UploadRequestOptions } from 'element-plus/es/components/upload/src/upload'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
UploadRawFile,
|
||||
UploadRequestOptions,
|
||||
UploadProgressEvent
|
||||
} from 'element-plus/es/components/upload/src/upload'
|
||||
import axios, { AxiosProgressEvent } from 'axios'
|
||||
|
||||
/**
|
||||
* 获得上传 URL
|
||||
@@ -17,22 +20,30 @@ export const useUpload = (directory?: string) => {
|
||||
const isClientUpload = UPLOAD_TYPE.CLIENT === import.meta.env.VITE_UPLOAD_TYPE
|
||||
// 重写ElUpload上传方法
|
||||
const httpRequest = async (options: UploadRequestOptions) => {
|
||||
// 文件上传进度监听
|
||||
const uploadProgressHandler = (evt: AxiosProgressEvent) => {
|
||||
const upEvt: UploadProgressEvent = Object.assign(evt.event)
|
||||
upEvt.percent = evt.progress ? evt.progress * 100 : 0
|
||||
options.onProgress(upEvt) // 触发 el-upload 的 on-progress
|
||||
}
|
||||
|
||||
// 模式一:前端上传
|
||||
if (isClientUpload) {
|
||||
// 1.1 生成文件名称
|
||||
const fileName = await generateFileName(options.file)
|
||||
const fileName = options.file.name || options.filename
|
||||
// 1.2 获取文件预签名地址
|
||||
const presignedInfo = await FileApi.getFilePresignedUrl(fileName, directory)
|
||||
// 1.3 上传文件(不能使用 ElUpload 的 ajaxUpload 方法的原因:其使用的是 FormData 上传,Minio 不支持)
|
||||
return axios
|
||||
.put(presignedInfo.uploadUrl, options.file, {
|
||||
headers: {
|
||||
'Content-Type': options.file.type
|
||||
}
|
||||
'Content-Type': options.file.type || 'application/octet-stream'
|
||||
},
|
||||
onUploadProgress: uploadProgressHandler
|
||||
})
|
||||
.then(() => {
|
||||
// 1.4. 记录文件信息到后端(异步)
|
||||
createFile(presignedInfo, options.file)
|
||||
createFile(presignedInfo, options.file, fileName)
|
||||
// 通知成功,数据格式保持与后端上传的返回结果一致
|
||||
return { data: presignedInfo.url }
|
||||
})
|
||||
@@ -40,7 +51,7 @@ export const useUpload = (directory?: string) => {
|
||||
// 模式二:后端上传
|
||||
// 重写 el-upload httpRequest 文件上传成功会走成功的钩子,失败走失败的钩子
|
||||
return new Promise((resolve, reject) => {
|
||||
FileApi.updateFile({ file: options.file, directory })
|
||||
FileApi.updateFile({ file: options.file, directory }, uploadProgressHandler)
|
||||
.then((res) => {
|
||||
if (res.code === 0) {
|
||||
resolve(res)
|
||||
@@ -64,38 +75,22 @@ export const useUpload = (directory?: string) => {
|
||||
/**
|
||||
* 创建文件信息
|
||||
* @param vo 文件预签名信息
|
||||
* @param name 文件名称
|
||||
* @param file 文件
|
||||
* @param fileName
|
||||
*/
|
||||
function createFile(vo: FileApi.FilePresignedUrlRespVO, file: UploadRawFile) {
|
||||
function createFile(vo: FileApi.FilePresignedUrlRespVO, file: UploadRawFile, fileName: string) {
|
||||
const fileVo = {
|
||||
configId: vo.configId,
|
||||
url: vo.url,
|
||||
path: vo.path,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
name: fileName,
|
||||
type: file.type || 'application/octet-stream',
|
||||
size: file.size
|
||||
}
|
||||
FileApi.createFile(fileVo)
|
||||
return fileVo
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件名称(使用算法SHA256)
|
||||
* @param file 要上传的文件
|
||||
*/
|
||||
async function generateFileName(file: UploadRawFile) {
|
||||
// // 读取文件内容
|
||||
// const data = await file.arrayBuffer()
|
||||
// const wordArray = CryptoJS.lib.WordArray.create(data)
|
||||
// // 计算SHA256
|
||||
// const sha256 = CryptoJS.SHA256(wordArray).toString()
|
||||
// // 拼接后缀
|
||||
// const ext = file.name.substring(file.name.lastIndexOf('.'))
|
||||
// return `${sha256}${ext}`
|
||||
return file.name
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传类型
|
||||
*/
|
||||
|
||||
@@ -36,14 +36,15 @@
|
||||
* Verify 验证码组件
|
||||
* @description 分发验证码使用
|
||||
* */
|
||||
import { VerifyPoints, VerifySlide } from './Verify'
|
||||
import {VerifyPictureWord, VerifyPoints, VerifySlide} from './Verify'
|
||||
import { computed, ref, toRefs, watchEffect } from 'vue'
|
||||
|
||||
export default {
|
||||
name: 'Vue3Verify',
|
||||
components: {
|
||||
VerifySlide,
|
||||
VerifyPoints
|
||||
VerifyPoints,
|
||||
VerifyPictureWord
|
||||
},
|
||||
props: {
|
||||
captchaType: {
|
||||
@@ -118,6 +119,10 @@ export default {
|
||||
}
|
||||
watchEffect(() => {
|
||||
switch (captchaType.value) {
|
||||
case 'pictureWord':
|
||||
verifyType.value = '3'
|
||||
componentType.value = 'VerifyPictureWord'
|
||||
break
|
||||
case 'blockPuzzle':
|
||||
verifyType.value = '2'
|
||||
componentType.value = 'VerifySlide'
|
||||
@@ -438,4 +443,4 @@ export default {
|
||||
content: ' ';
|
||||
inset: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
196
src/components/Verifition/src/Verify/VerifyPictureWord.vue
Normal file
196
src/components/Verifition/src/Verify/VerifyPictureWord.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div style="position: relative">
|
||||
<div class="verify-img-out">
|
||||
<div
|
||||
:style="{
|
||||
width: setSize.imgWidth,
|
||||
height: setSize.imgHeight,
|
||||
'background-size': setSize.imgWidth + ' ' + setSize.imgHeight,
|
||||
'margin-bottom': vSpace + 'px'
|
||||
}"
|
||||
class="verify-img-panel"
|
||||
>
|
||||
<div v-show="showRefresh" class="verify-refresh" style="z-index: 3" @click="refresh">
|
||||
<i class="iconfont icon-refresh"></i>
|
||||
</div>
|
||||
<img
|
||||
@click="refresh"
|
||||
ref="canvas"
|
||||
:src="'data:image/png;base64,' + verificationCodeImg"
|
||||
alt=""
|
||||
style="display: block; width: 100%; height: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:style="{
|
||||
width: setSize.imgWidth,
|
||||
color: barAreaColor,
|
||||
'border-color': barAreaBorderColor
|
||||
// 'line-height': barSize.height
|
||||
}"
|
||||
class="verify-bar-area"
|
||||
>
|
||||
<div class="verify-msg">{{ text }}</div>
|
||||
<div
|
||||
:style="{
|
||||
'line-height': barSize.height
|
||||
}"
|
||||
>
|
||||
<input class="verify-input" type="text" v-model="userCode" />
|
||||
</div>
|
||||
<button type="button" class="verify-btn" @click="submit" :disabled="checking">{{
|
||||
t('captcha.verify')
|
||||
}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup type="text/babel">
|
||||
/**
|
||||
* VerifyPictureWord
|
||||
* @description 输入文字
|
||||
* */
|
||||
import { resetSize } from '../utils/util'
|
||||
import { aesEncrypt } from '../utils/ase'
|
||||
import { getCode, reqCheck } from '@/api/login'
|
||||
import { getCurrentInstance, nextTick, onMounted, reactive, ref, toRefs } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
// 弹出式 pop,固定 fixed
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'fixed'
|
||||
},
|
||||
captchaType: {
|
||||
type: String
|
||||
},
|
||||
// 间隔
|
||||
vSpace: {
|
||||
type: Number,
|
||||
default: 5
|
||||
},
|
||||
imgSize: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {
|
||||
width: '310px',
|
||||
height: '155px'
|
||||
}
|
||||
}
|
||||
},
|
||||
barSize: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {
|
||||
width: '310px',
|
||||
height: '40px'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const { mode, captchaType } = toRefs(props)
|
||||
const { proxy } = getCurrentInstance()
|
||||
let secretKey = ref(''), // 后端返回的ase加密秘钥
|
||||
userCode = ref(''), // 用户输入的验证码 暂存至pointJson,无需加密
|
||||
verificationCodeImg = ref(''), // 后端获取到的背景图片
|
||||
backToken = ref(''), // 后端返回的token值
|
||||
setSize = reactive({
|
||||
imgHeight: 0,
|
||||
imgWidth: 0,
|
||||
barHeight: 0,
|
||||
barWidth: 0
|
||||
}),
|
||||
text = ref(''),
|
||||
barAreaColor = ref('#000'),
|
||||
barAreaBorderColor = ref('#ddd'),
|
||||
showRefresh = ref(true),
|
||||
// bindingClick = ref(true)
|
||||
checking = ref(false)
|
||||
|
||||
const init = () => {
|
||||
// 加载页面
|
||||
getPicture()
|
||||
nextTick(() => {
|
||||
let { imgHeight, imgWidth, barHeight, barWidth } = resetSize(proxy)
|
||||
setSize.imgHeight = imgHeight
|
||||
setSize.imgWidth = imgWidth
|
||||
setSize.barHeight = barHeight
|
||||
setSize.barWidth = barWidth
|
||||
proxy.$parent.$emit('ready', proxy)
|
||||
})
|
||||
}
|
||||
onMounted(() => {
|
||||
// 禁止拖拽
|
||||
init()
|
||||
proxy.$el.onselectstart = function () {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const canvas = ref(null)
|
||||
|
||||
const submit = () => {
|
||||
checking.value = true
|
||||
// 发送后端请求
|
||||
const captchaVerification = secretKey.value
|
||||
? aesEncrypt(backToken.value + '---' + userCode.value, secretKey.value)
|
||||
: backToken.value + '---' + userCode.value
|
||||
let data = {
|
||||
captchaType: captchaType.value,
|
||||
pointJson: userCode.value,
|
||||
token: backToken.value
|
||||
}
|
||||
reqCheck(data).then((res) => {
|
||||
if (res.repCode === '0000') {
|
||||
barAreaColor.value = '#4cae4c'
|
||||
barAreaBorderColor.value = '#5cb85c'
|
||||
text.value = t('captcha.success')
|
||||
// bindingClick.value = false
|
||||
if (mode.value === 'pop') {
|
||||
setTimeout(() => {
|
||||
proxy.$parent.clickShow = false
|
||||
refresh()
|
||||
}, 1500)
|
||||
}
|
||||
proxy.$parent.$emit('success', { captchaVerification })
|
||||
} else {
|
||||
proxy.$parent.$emit('error', proxy)
|
||||
barAreaColor.value = '#d9534f'
|
||||
barAreaBorderColor.value = '#d9534f'
|
||||
text.value = t('captcha.fail')
|
||||
setTimeout(() => {
|
||||
refresh()
|
||||
}, 700)
|
||||
}
|
||||
checking.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const refresh = async function () {
|
||||
barAreaColor.value = '#000'
|
||||
barAreaBorderColor.value = '#ddd'
|
||||
checking.value = false
|
||||
|
||||
userCode.value = ''
|
||||
|
||||
await getPicture()
|
||||
showRefresh.value = true
|
||||
}
|
||||
|
||||
// 请求背景图片和验证图片
|
||||
const getPicture = async () => {
|
||||
let data = {
|
||||
captchaType: captchaType.value
|
||||
}
|
||||
const res = await getCode(data)
|
||||
if (res.repCode === '0000') {
|
||||
verificationCodeImg.value = res.repData.originalImageBase64
|
||||
backToken.value = res.repData.token
|
||||
secretKey.value = res.repData.secretKey
|
||||
text.value = t('captcha.code')
|
||||
} else {
|
||||
text.value = res.repMsg
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,4 +1,5 @@
|
||||
import VerifySlide from './VerifySlide.vue'
|
||||
import VerifyPoints from './VerifyPoints.vue'
|
||||
import VerifyPictureWord from './VerifyPictureWord.vue'
|
||||
|
||||
export { VerifySlide, VerifyPoints }
|
||||
export { VerifySlide, VerifyPoints, VerifyPictureWord }
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
'Append Gateway': '追加网关',
|
||||
'Append Task': '追加任务',
|
||||
'Append Intermediate/Boundary Event': '追加中间抛出事件/边界事件',
|
||||
|
||||
TextAnnotation: '文本注释',
|
||||
'Activate the global connect tool': '激活全局连接工具',
|
||||
'Append {type}': '添加 {type}',
|
||||
'Add Lane above': '在上面添加道',
|
||||
@@ -29,10 +29,16 @@ export default {
|
||||
'Create expanded SubProcess': '创建扩展子过程',
|
||||
'Create IntermediateThrowEvent/BoundaryEvent': '创建中间抛出事件/边界事件',
|
||||
'Create Pool/Participant': '创建池/参与者',
|
||||
'Parallel Multi Instance': '并行多重事件',
|
||||
'Sequential Multi Instance': '时序多重事件',
|
||||
'Participant Multiplicity': '参与者多重性',
|
||||
'Empty pool/participant (removes content)': '清空池/参与者(移除内容)',
|
||||
'Empty pool/participant': '收缩池/参与者',
|
||||
'Expanded pool/participant': '展开池/参与者',
|
||||
'Parallel Multi-Instance': '并行多重事件',
|
||||
'Sequential Multi-Instance': '时序多重事件',
|
||||
DataObjectReference: '数据对象参考',
|
||||
DataStoreReference: '数据存储参考',
|
||||
'Data object reference': '数据对象引用 ',
|
||||
'Data store reference': '数据存储引用 ',
|
||||
Loop: '循环',
|
||||
'Ad-hoc': '即席',
|
||||
'Create {type}': '创建 {type}',
|
||||
@@ -47,6 +53,9 @@ export default {
|
||||
'Call Activity': '调用活动',
|
||||
'Sub-Process (collapsed)': '子流程(折叠的)',
|
||||
'Sub-Process (expanded)': '子流程(展开的)',
|
||||
'Ad-hoc sub-process': '即席子流程',
|
||||
'Ad-hoc sub-process (collapsed)': '即席子流程(折叠的)',
|
||||
'Ad-hoc sub-process (expanded)': '即席子流程(展开的)',
|
||||
'Start Event': '开始事件',
|
||||
StartEvent: '开始事件',
|
||||
'Intermediate Throw Event': '中间事件',
|
||||
@@ -109,10 +118,10 @@ export default {
|
||||
'Parallel Gateway': '并行网关',
|
||||
'Inclusive Gateway': '相容网关',
|
||||
'Complex Gateway': '复杂网关',
|
||||
'Event based Gateway': '事件网关',
|
||||
'Event-based Gateway': '事件网关',
|
||||
Transaction: '转运',
|
||||
'Sub Process': '子流程',
|
||||
'Event Sub Process': '事件子流程',
|
||||
'sub-process': '子流程',
|
||||
'Event sub-process': '事件子流程',
|
||||
'Collapsed Pool': '折叠池',
|
||||
'Expanded Pool': '展开池',
|
||||
|
||||
|
||||
@@ -71,7 +71,8 @@
|
||||
<!-- 新增的时间事件配置项 -->
|
||||
<el-collapse-item v-if="elementType === 'IntermediateCatchEvent'" name="timeEvent">
|
||||
<template #title><Icon icon="ep:timer" />时间事件</template>
|
||||
<TimeEventConfig :businessObject="bpmnElement.value?.businessObject" :key="elementId" />
|
||||
<!-- 相关 issue:https://gitee.com/yudaocode/yudao-ui-admin-vue3/issues/ICNRW2 -->
|
||||
<TimeEventConfig :businessObject="elementBusinessObject" :key="elementId" />
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
/></el-button>
|
||||
</div>
|
||||
<div class="button-setting-item-label">
|
||||
<el-switch v-model="item.enable" />
|
||||
<el-switch v-model="item.enable" @change="updateElementExtensions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,7 +241,13 @@ const assignEmptyUserIds = ref()
|
||||
|
||||
// 操作按钮
|
||||
const buttonsSettingEl = ref()
|
||||
const { btnDisplayNameEdit, changeBtnDisplayName, btnDisplayNameBlurEvent } = useButtonsSetting()
|
||||
const { btnDisplayNameEdit, changeBtnDisplayName } = useButtonsSetting()
|
||||
const btnDisplayNameBlurEvent = (index: number) => {
|
||||
btnDisplayNameEdit.value[index] = false
|
||||
const buttonItem = buttonsSettingEl.value[index]
|
||||
buttonItem.displayName = buttonItem.displayName || OPERATION_BUTTON_NAME.get(buttonItem.id)!
|
||||
updateElementExtensions()
|
||||
}
|
||||
|
||||
// 字段权限
|
||||
const fieldsPermissionEl = ref([])
|
||||
@@ -495,16 +501,10 @@ function useButtonsSetting() {
|
||||
const changeBtnDisplayName = (index: number) => {
|
||||
btnDisplayNameEdit.value[index] = true
|
||||
}
|
||||
const btnDisplayNameBlurEvent = (index: number) => {
|
||||
btnDisplayNameEdit.value[index] = false
|
||||
const buttonItem = buttonsSetting.value![index]
|
||||
buttonItem.displayName = buttonItem.displayName || OPERATION_BUTTON_NAME.get(buttonItem.id)!
|
||||
}
|
||||
return {
|
||||
buttonsSetting,
|
||||
btnDisplayNameEdit,
|
||||
changeBtnDisplayName,
|
||||
btnDisplayNameBlurEvent
|
||||
changeBtnDisplayName
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -282,7 +282,6 @@ const editingListenerIndex = ref(-1) // 监听器所在下标,-1 为新增
|
||||
const editingListenerFieldIndex = ref(-1) // 字段所在下标,-1 为新增
|
||||
const listenerTypeObject = ref(listenerType)
|
||||
const fieldTypeObject = ref(fieldType)
|
||||
const bpmnElement = ref()
|
||||
const otherExtensionList = ref()
|
||||
const bpmnElementListeners = ref()
|
||||
const listenerFormRef = ref()
|
||||
@@ -290,10 +289,19 @@ const listenerFieldFormRef = ref()
|
||||
const bpmnInstances = () => (window as any)?.bpmnInstances
|
||||
|
||||
const resetListenersList = () => {
|
||||
bpmnElement.value = bpmnInstances().bpmnElement
|
||||
otherExtensionList.value = []
|
||||
const instances = bpmnInstances()
|
||||
if (!instances || !instances.bpmnElement) return
|
||||
|
||||
// 直接使用原始BPMN元素,避免Vue响应式代理问题
|
||||
const bpmnElement = instances.bpmnElement
|
||||
const businessObject = bpmnElement.businessObject
|
||||
|
||||
otherExtensionList.value =
|
||||
businessObject?.extensionElements?.values?.filter(
|
||||
(ex) => ex.$type !== `${prefix}:ExecutionListener`
|
||||
) ?? [] // 保留非监听器类型的扩展属性,避免移除监听器时清空其他配置(如审批人等)。相关案例:https://gitee.com/yudaocode/yudao-ui-admin-vue3/issues/ICMSYC
|
||||
bpmnElementListeners.value =
|
||||
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
|
||||
businessObject?.extensionElements?.values?.filter(
|
||||
(ex) => ex.$type === `${prefix}:ExecutionListener`
|
||||
) ?? []
|
||||
elementListenersList.value = bpmnElementListeners.value.map((listener) =>
|
||||
@@ -375,10 +383,13 @@ const removeListener = (index) => {
|
||||
cancelButtonText: '取 消'
|
||||
})
|
||||
.then(() => {
|
||||
const instances = bpmnInstances()
|
||||
if (!instances || !instances.bpmnElement) return
|
||||
|
||||
bpmnElementListeners.value.splice(index, 1)
|
||||
elementListenersList.value.splice(index, 1)
|
||||
updateElementExtensions(
|
||||
bpmnElement.value,
|
||||
instances.bpmnElement,
|
||||
otherExtensionList.value.concat(bpmnElementListeners.value)
|
||||
)
|
||||
})
|
||||
@@ -389,7 +400,13 @@ const saveListenerConfig = async () => {
|
||||
// debugger
|
||||
let validateStatus = await listenerFormRef.value.validate()
|
||||
if (!validateStatus) return // 验证不通过直接返回
|
||||
|
||||
const instances = bpmnInstances()
|
||||
if (!instances || !instances.bpmnElement) return
|
||||
|
||||
const bpmnElement = instances.bpmnElement
|
||||
const listenerObject = createListenerObject(listenerForm.value, false, prefix)
|
||||
|
||||
if (editingListenerIndex.value === -1) {
|
||||
bpmnElementListeners.value.push(listenerObject)
|
||||
elementListenersList.value.push(listenerForm.value)
|
||||
@@ -399,11 +416,11 @@ const saveListenerConfig = async () => {
|
||||
}
|
||||
// 保存其他配置
|
||||
otherExtensionList.value =
|
||||
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
|
||||
bpmnElement.businessObject?.extensionElements?.values?.filter(
|
||||
(ex) => ex.$type !== `${prefix}:ExecutionListener`
|
||||
) ?? []
|
||||
updateElementExtensions(
|
||||
bpmnElement.value,
|
||||
bpmnElement,
|
||||
otherExtensionList.value.concat(bpmnElementListeners.value)
|
||||
)
|
||||
// 4. 隐藏侧边栏
|
||||
@@ -417,6 +434,10 @@ const openProcessListenerDialog = async () => {
|
||||
processListenerDialogRef.value.open('execution')
|
||||
}
|
||||
const selectProcessListener = (listener) => {
|
||||
const instances = bpmnInstances()
|
||||
if (!instances || !instances.bpmnElement) return
|
||||
|
||||
const bpmnElement = instances.bpmnElement
|
||||
const listenerForm = initListenerForm2(listener)
|
||||
const listenerObject = createListenerObject(listenerForm, false, prefix)
|
||||
bpmnElementListeners.value.push(listenerObject)
|
||||
@@ -424,11 +445,11 @@ const selectProcessListener = (listener) => {
|
||||
|
||||
// 保存其他配置
|
||||
otherExtensionList.value =
|
||||
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
|
||||
bpmnElement.businessObject?.extensionElements?.values?.filter(
|
||||
(ex) => ex.$type !== `${prefix}:ExecutionListener`
|
||||
) ?? []
|
||||
updateElementExtensions(
|
||||
bpmnElement.value,
|
||||
bpmnElement,
|
||||
otherExtensionList.value.concat(bpmnElementListeners.value)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -329,7 +329,6 @@ const listenerFieldFormModelVisible = ref(false) // 监听器 注入字段表单
|
||||
const editingListenerIndex = ref(-1) // 监听器所在下标,-1 为新增
|
||||
const editingListenerFieldIndex = ref(-1) // 字段所在下标,-1 为新增
|
||||
const listenerFieldForm = ref<any>({}) // 监听器 注入字段 详情表单
|
||||
const bpmnElement = ref()
|
||||
const bpmnElementListeners = ref()
|
||||
const otherExtensionList = ref()
|
||||
const listenerFormRef = ref()
|
||||
@@ -337,14 +336,21 @@ const listenerFieldFormRef = ref()
|
||||
const bpmnInstances = () => (window as any)?.bpmnInstances
|
||||
|
||||
const resetListenersList = () => {
|
||||
console.log(
|
||||
bpmnInstances().bpmnElement,
|
||||
'window.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElement'
|
||||
)
|
||||
bpmnElement.value = bpmnInstances().bpmnElement
|
||||
otherExtensionList.value = []
|
||||
const instances = bpmnInstances()
|
||||
if (!instances || !instances.bpmnElement) return
|
||||
|
||||
// 直接使用原始BPMN元素,避免Vue响应式代理问题
|
||||
const bpmnElement = instances.bpmnElement
|
||||
const businessObject = bpmnElement.businessObject
|
||||
|
||||
console.log(bpmnElement, 'bpmnElement - resetListenersList')
|
||||
|
||||
otherExtensionList.value =
|
||||
businessObject?.extensionElements?.values?.filter(
|
||||
(ex) => ex.$type !== `${prefix}:TaskListener`
|
||||
) ?? [] // 保留非监听器类型的扩展属性,避免移除监听器时清空其他配置(如审批人等)。相关案例:https://gitee.com/yudaocode/yudao-ui-admin-vue3/issues/ICMSYC
|
||||
bpmnElementListeners.value =
|
||||
bpmnElement.value.businessObject?.extensionElements?.values.filter(
|
||||
businessObject?.extensionElements?.values?.filter(
|
||||
(ex) => ex.$type === `${prefix}:TaskListener`
|
||||
) ?? []
|
||||
elementListenersList.value = bpmnElementListeners.value.map((listener) =>
|
||||
@@ -382,10 +388,13 @@ const removeListener = (listener, index?) => {
|
||||
cancelButtonText: '取 消'
|
||||
})
|
||||
.then(() => {
|
||||
const instances = bpmnInstances()
|
||||
if (!instances || !instances.bpmnElement) return
|
||||
|
||||
bpmnElementListeners.value.splice(index, 1)
|
||||
elementListenersList.value.splice(index, 1)
|
||||
updateElementExtensions(
|
||||
bpmnElement.value,
|
||||
instances.bpmnElement,
|
||||
otherExtensionList.value.concat(bpmnElementListeners.value)
|
||||
)
|
||||
})
|
||||
@@ -395,7 +404,13 @@ const removeListener = (listener, index?) => {
|
||||
const saveListenerConfig = async () => {
|
||||
let validateStatus = await listenerFormRef.value.validate()
|
||||
if (!validateStatus) return // 验证不通过直接返回
|
||||
|
||||
const instances = bpmnInstances()
|
||||
if (!instances || !instances.bpmnElement) return
|
||||
|
||||
const bpmnElement = instances.bpmnElement
|
||||
const listenerObject = createListenerObject(listenerForm.value, true, prefix)
|
||||
|
||||
if (editingListenerIndex.value === -1) {
|
||||
bpmnElementListeners.value.push(listenerObject)
|
||||
elementListenersList.value.push(listenerForm.value)
|
||||
@@ -405,11 +420,11 @@ const saveListenerConfig = async () => {
|
||||
}
|
||||
// 保存其他配置
|
||||
otherExtensionList.value =
|
||||
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
|
||||
bpmnElement.businessObject?.extensionElements?.values?.filter(
|
||||
(ex) => ex.$type !== `${prefix}:TaskListener`
|
||||
) ?? []
|
||||
updateElementExtensions(
|
||||
bpmnElement.value,
|
||||
bpmnElement,
|
||||
otherExtensionList.value.concat(bpmnElementListeners.value)
|
||||
)
|
||||
// 4. 隐藏侧边栏
|
||||
@@ -461,6 +476,10 @@ const openProcessListenerDialog = async () => {
|
||||
processListenerDialogRef.value.open('task')
|
||||
}
|
||||
const selectProcessListener = (listener) => {
|
||||
const instances = bpmnInstances()
|
||||
if (!instances || !instances.bpmnElement) return
|
||||
|
||||
const bpmnElement = instances.bpmnElement
|
||||
const listenerForm = initListenerForm2(listener)
|
||||
const listenerObject = createListenerObject(listenerForm, true, prefix)
|
||||
bpmnElementListeners.value.push(listenerObject)
|
||||
@@ -468,11 +487,11 @@ const selectProcessListener = (listener) => {
|
||||
|
||||
// 保存其他配置
|
||||
otherExtensionList.value =
|
||||
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
|
||||
bpmnElement.businessObject?.extensionElements?.values?.filter(
|
||||
(ex) => ex.$type !== `${prefix}:TaskListener`
|
||||
) ?? []
|
||||
updateElementExtensions(
|
||||
bpmnElement.value,
|
||||
bpmnElement,
|
||||
otherExtensionList.value.concat(bpmnElementListeners.value)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -67,7 +67,6 @@ const elementPropertyList = ref<any[]>([])
|
||||
const propertyForm = ref<any>({})
|
||||
const editingPropertyIndex = ref(-1)
|
||||
const propertyFormModelVisible = ref(false)
|
||||
const bpmnElement = ref()
|
||||
const otherExtensionList = ref()
|
||||
const bpmnElementProperties = ref()
|
||||
const bpmnElementPropertyList = ref()
|
||||
@@ -75,16 +74,21 @@ const attributeFormRef = ref()
|
||||
const bpmnInstances = () => (window as any)?.bpmnInstances
|
||||
|
||||
const resetAttributesList = () => {
|
||||
bpmnElement.value = bpmnInstances().bpmnElement
|
||||
const instances = bpmnInstances()
|
||||
if (!instances || !instances.bpmnElement) return
|
||||
|
||||
// 直接使用原始BPMN元素,避免Vue响应式代理问题
|
||||
const bpmnElement = instances.bpmnElement
|
||||
const businessObject = bpmnElement.businessObject
|
||||
|
||||
otherExtensionList.value = [] // 其他扩展配置
|
||||
bpmnElementProperties.value =
|
||||
// bpmnElement.value.businessObject?.extensionElements?.filter((ex) => {
|
||||
bpmnElement.value.businessObject?.extensionElements?.values?.filter((ex) => {
|
||||
businessObject?.extensionElements?.values?.filter((ex) => {
|
||||
if (ex.$type !== `${prefix}:Properties`) {
|
||||
otherExtensionList.value.push(ex)
|
||||
}
|
||||
return ex.$type === `${prefix}:Properties`
|
||||
}) ?? [];
|
||||
}) ?? []
|
||||
|
||||
// 保存所有的 扩展属性字段
|
||||
bpmnElementPropertyList.value = bpmnElementProperties.value.reduce(
|
||||
@@ -123,10 +127,15 @@ const removeAttributes = (attr, index) => {
|
||||
const saveAttribute = () => {
|
||||
console.log(propertyForm.value, 'propertyForm.value')
|
||||
const { name, value } = propertyForm.value
|
||||
const instances = bpmnInstances()
|
||||
if (!instances || !instances.bpmnElement) return
|
||||
|
||||
const bpmnElement = instances.bpmnElement
|
||||
|
||||
if (editingPropertyIndex.value !== -1) {
|
||||
bpmnInstances().modeling.updateModdleProperties(
|
||||
toRaw(bpmnElement.value),
|
||||
toRaw(bpmnElementPropertyList.value)[toRaw(editingPropertyIndex.value)],
|
||||
instances.modeling.updateModdleProperties(
|
||||
bpmnElement,
|
||||
bpmnElementPropertyList.value[editingPropertyIndex.value],
|
||||
{
|
||||
name,
|
||||
value
|
||||
@@ -134,12 +143,12 @@ const saveAttribute = () => {
|
||||
)
|
||||
} else {
|
||||
// 新建属性字段
|
||||
const newPropertyObject = bpmnInstances().moddle.create(`${prefix}:Property`, {
|
||||
const newPropertyObject = instances.moddle.create(`${prefix}:Property`, {
|
||||
name,
|
||||
value
|
||||
})
|
||||
// 新建一个属性字段的保存列表
|
||||
const propertiesObject = bpmnInstances().moddle.create(`${prefix}:Properties`, {
|
||||
const propertiesObject = instances.moddle.create(`${prefix}:Properties`, {
|
||||
values: bpmnElementPropertyList.value.concat([newPropertyObject])
|
||||
})
|
||||
updateElementExtensions(propertiesObject)
|
||||
@@ -148,10 +157,14 @@ const saveAttribute = () => {
|
||||
resetAttributesList()
|
||||
}
|
||||
const updateElementExtensions = (properties) => {
|
||||
const extensions = bpmnInstances().moddle.create('bpmn:ExtensionElements', {
|
||||
const instances = bpmnInstances()
|
||||
if (!instances || !instances.bpmnElement) return
|
||||
|
||||
const bpmnElement = instances.bpmnElement
|
||||
const extensions = instances.moddle.create('bpmn:ExtensionElements', {
|
||||
values: otherExtensionList.value.concat([properties])
|
||||
})
|
||||
bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), {
|
||||
instances.modeling.updateProperties(bpmnElement, {
|
||||
extensionElements: extensions
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,8 +6,25 @@
|
||||
</div>
|
||||
<el-table :data="messageList" border>
|
||||
<el-table-column type="index" label="序号" width="60px" />
|
||||
<el-table-column label="消息ID" prop="id" max-width="300px" show-overflow-tooltip />
|
||||
<el-table-column label="消息名称" prop="name" max-width="300px" show-overflow-tooltip />
|
||||
<el-table-column label="消息ID" prop="id" min-width="120px" show-overflow-tooltip />
|
||||
<el-table-column label="消息名称" prop="name" min-width="120px" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="110px">
|
||||
<!-- 补充“编辑”、“移除”功能。相关 issue:https://github.com/YunaiV/yudao-cloud/issues/270 -->
|
||||
<template #default="scope">
|
||||
<el-button link @click="openEditModel('message', scope.row, scope.$index)" size="small">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button
|
||||
link
|
||||
size="small"
|
||||
style="color: #ff4d4f"
|
||||
@click="removeObject('message', scope.row)"
|
||||
>
|
||||
移除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div
|
||||
class="panel-tab__content--title"
|
||||
@@ -18,8 +35,24 @@
|
||||
</div>
|
||||
<el-table :data="signalList" border>
|
||||
<el-table-column type="index" label="序号" width="60px" />
|
||||
<el-table-column label="信号ID" prop="id" max-width="300px" show-overflow-tooltip />
|
||||
<el-table-column label="信号名称" prop="name" max-width="300px" show-overflow-tooltip />
|
||||
<el-table-column label="信号ID" prop="id" min-width="120px" show-overflow-tooltip />
|
||||
<el-table-column label="信号名称" prop="name" min-width="120px" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="110px">
|
||||
<template #default="scope">
|
||||
<el-button link @click="openEditModel('signal', scope.row, scope.$index)" size="small">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button
|
||||
link
|
||||
size="small"
|
||||
style="color: #ff4d4f"
|
||||
@click="removeObject('signal', scope.row)"
|
||||
>
|
||||
移除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog
|
||||
@@ -46,6 +79,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
defineOptions({ name: 'SignalAndMassage' })
|
||||
|
||||
const message = useMessage()
|
||||
@@ -57,15 +91,33 @@ const modelObjectForm = ref<any>({})
|
||||
const rootElements = ref()
|
||||
const messageIdMap = ref()
|
||||
const signalIdMap = ref()
|
||||
const editingIndex = ref(-1) // 正在编辑的索引,-1 表示新建
|
||||
const modelConfig = computed(() => {
|
||||
const isEdit = editingIndex.value !== -1
|
||||
if (modelType.value === 'message') {
|
||||
return { title: '创建消息', idLabel: '消息ID', nameLabel: '消息名称' }
|
||||
return {
|
||||
title: isEdit ? '编辑消息' : '创建消息',
|
||||
idLabel: '消息ID',
|
||||
nameLabel: '消息名称'
|
||||
}
|
||||
} else {
|
||||
return { title: '创建信号', idLabel: '信号ID', nameLabel: '信号名称' }
|
||||
return {
|
||||
title: isEdit ? '编辑信号' : '创建信号',
|
||||
idLabel: '信号ID',
|
||||
nameLabel: '信号名称'
|
||||
}
|
||||
}
|
||||
})
|
||||
const bpmnInstances = () => (window as any)?.bpmnInstances
|
||||
|
||||
// 生成规范化的ID
|
||||
const generateStandardId = (type: string): string => {
|
||||
const prefix = type === 'message' ? 'Message_' : 'Signal_'
|
||||
const timestamp = Date.now()
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase()
|
||||
return `${prefix}${timestamp}_${random}`
|
||||
}
|
||||
|
||||
const initDataList = () => {
|
||||
console.log(window, 'window')
|
||||
rootElements.value = bpmnInstances().modeler.getDefinitions().rootElements
|
||||
@@ -86,27 +138,126 @@ const initDataList = () => {
|
||||
}
|
||||
const openModel = (type) => {
|
||||
modelType.value = type
|
||||
modelObjectForm.value = {}
|
||||
editingIndex.value = -1
|
||||
modelObjectForm.value = {
|
||||
id: generateStandardId(type),
|
||||
name: ''
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const openEditModel = (type, row, index) => {
|
||||
modelType.value = type
|
||||
editingIndex.value = index
|
||||
modelObjectForm.value = { ...row }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
const addNewObject = () => {
|
||||
if (modelType.value === 'message') {
|
||||
if (messageIdMap.value[modelObjectForm.value.id]) {
|
||||
message.error('该消息已存在,请修改id后重新保存')
|
||||
// 编辑模式
|
||||
if (editingIndex.value !== -1) {
|
||||
const targetMessage = messageList.value[editingIndex.value]
|
||||
// 查找 rootElements 中的原始对象
|
||||
const rootMessage = rootElements.value.find(
|
||||
(el) => el.$type === 'bpmn:Message' && el.id === targetMessage.id
|
||||
)
|
||||
if (rootMessage) {
|
||||
rootMessage.id = modelObjectForm.value.id
|
||||
rootMessage.name = modelObjectForm.value.name
|
||||
}
|
||||
} else {
|
||||
// 新建模式
|
||||
if (messageIdMap.value[modelObjectForm.value.id]) {
|
||||
message.error('该消息已存在,请修改id后重新保存')
|
||||
return
|
||||
}
|
||||
const messageRef = bpmnInstances().moddle.create('bpmn:Message', modelObjectForm.value)
|
||||
rootElements.value.push(messageRef)
|
||||
}
|
||||
const messageRef = bpmnInstances().moddle.create('bpmn:Message', modelObjectForm.value)
|
||||
rootElements.value.push(messageRef)
|
||||
} else {
|
||||
if (signalIdMap.value[modelObjectForm.value.id]) {
|
||||
message.error('该信号已存在,请修改id后重新保存')
|
||||
// 编辑模式
|
||||
if (editingIndex.value !== -1) {
|
||||
const targetSignal = signalList.value[editingIndex.value]
|
||||
// 查找 rootElements 中的原始对象
|
||||
const rootSignal = rootElements.value.find(
|
||||
(el) => el.$type === 'bpmn:Signal' && el.id === targetSignal.id
|
||||
)
|
||||
if (rootSignal) {
|
||||
rootSignal.id = modelObjectForm.value.id
|
||||
rootSignal.name = modelObjectForm.value.name
|
||||
}
|
||||
} else {
|
||||
// 新建模式
|
||||
if (signalIdMap.value[modelObjectForm.value.id]) {
|
||||
message.error('该信号已存在,请修改id后重新保存')
|
||||
return
|
||||
}
|
||||
const signalRef = bpmnInstances().moddle.create('bpmn:Signal', modelObjectForm.value)
|
||||
rootElements.value.push(signalRef)
|
||||
}
|
||||
const signalRef = bpmnInstances().moddle.create('bpmn:Signal', modelObjectForm.value)
|
||||
rootElements.value.push(signalRef)
|
||||
}
|
||||
dialogVisible.value = false
|
||||
// 触发建模器更新以保存更改
|
||||
saveChanges()
|
||||
initDataList()
|
||||
}
|
||||
|
||||
const removeObject = (type, row) => {
|
||||
ElMessageBox.confirm(`确认移除该${type === 'message' ? '消息' : '信号'}吗?`, '提示', {
|
||||
confirmButtonText: '确 认',
|
||||
cancelButtonText: '取 消'
|
||||
})
|
||||
.then(() => {
|
||||
// 从 rootElements 中移除
|
||||
const targetType = type === 'message' ? 'bpmn:Message' : 'bpmn:Signal'
|
||||
const elementIndex = rootElements.value.findIndex(
|
||||
(el) => el.$type === targetType && el.id === row.id
|
||||
)
|
||||
if (elementIndex !== -1) {
|
||||
rootElements.value.splice(elementIndex, 1)
|
||||
}
|
||||
// 触发建模器更新以保存更改
|
||||
saveChanges()
|
||||
// 刷新列表
|
||||
initDataList()
|
||||
message.success('移除成功')
|
||||
})
|
||||
.catch(() => console.info('操作取消'))
|
||||
}
|
||||
|
||||
// 触发建模器更新以保存更改
|
||||
const saveChanges = () => {
|
||||
const modeler = bpmnInstances().modeler
|
||||
if (!modeler) return
|
||||
|
||||
try {
|
||||
// 获取 canvas,通过它来触发图表的重新渲染
|
||||
const canvas = modeler.get('canvas')
|
||||
|
||||
// 获取根元素(Process)
|
||||
const rootElement = canvas.getRootElement()
|
||||
|
||||
// 触发 changed 事件,通知建模器数据已更改
|
||||
const eventBus = modeler.get('eventBus')
|
||||
if (eventBus) {
|
||||
eventBus.fire('root.added', { element: rootElement })
|
||||
eventBus.fire('elements.changed', { elements: [rootElement] })
|
||||
}
|
||||
|
||||
// 标记建模器为已修改状态
|
||||
const commandStack = modeler.get('commandStack')
|
||||
if (commandStack && commandStack._stack) {
|
||||
// 添加一个空命令以标记为已修改
|
||||
commandStack.execute('element.updateProperties', {
|
||||
element: rootElement,
|
||||
properties: {}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('保存更改时出错:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initDataList()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="编辑请求头"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="header-editor">
|
||||
<div class="header-list">
|
||||
<div v-for="(item, index) in headerList" :key="index" class="header-item">
|
||||
<el-input v-model="item.key" placeholder="请输入参数名" class="header-key" clearable />
|
||||
<span class="separator">:</span>
|
||||
<el-input
|
||||
v-model="item.value"
|
||||
placeholder="请输入参数值 (支持表达式 ${变量名})"
|
||||
class="header-value"
|
||||
clearable
|
||||
/>
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
circle
|
||||
size="small"
|
||||
@click="removeHeader(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" class="add-btn" @click="addHeader">
|
||||
添加请求头
|
||||
</el-button>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Delete, Plus } from '@element-plus/icons-vue'
|
||||
|
||||
defineOptions({ name: 'HttpHeaderEditor' })
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
headers: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save'])
|
||||
|
||||
interface HeaderItem {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const headerList = ref<HeaderItem[]>([])
|
||||
|
||||
// 解析请求头字符串为列表
|
||||
const parseHeaders = (headersStr: string): HeaderItem[] => {
|
||||
if (!headersStr || !headersStr.trim()) {
|
||||
return [{ key: '', value: '' }]
|
||||
}
|
||||
|
||||
const lines = headersStr.split('\n').filter((line) => line.trim())
|
||||
const parsed = lines.map((line) => {
|
||||
const colonIndex = line.indexOf(':')
|
||||
if (colonIndex > 0) {
|
||||
return {
|
||||
key: line.substring(0, colonIndex).trim(),
|
||||
value: line.substring(colonIndex + 1).trim()
|
||||
}
|
||||
}
|
||||
return { key: line.trim(), value: '' }
|
||||
})
|
||||
|
||||
return parsed.length > 0 ? parsed : [{ key: '', value: '' }]
|
||||
}
|
||||
|
||||
// 将列表转换为请求头字符串
|
||||
const stringifyHeaders = (headers: HeaderItem[]): string => {
|
||||
return headers
|
||||
.filter((item) => item.key.trim())
|
||||
.map((item) => `${item.key}: ${item.value}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
// 添加请求头
|
||||
const addHeader = () => {
|
||||
headerList.value.push({ key: '', value: '' })
|
||||
}
|
||||
|
||||
// 移除请求头
|
||||
const removeHeader = (index: number) => {
|
||||
if (headerList.value.length === 1) {
|
||||
// 至少保留一行
|
||||
headerList.value = [{ key: '', value: '' }]
|
||||
} else {
|
||||
headerList.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存
|
||||
const handleSave = () => {
|
||||
const headersStr = stringifyHeaders(headerList.value)
|
||||
emit('save', headersStr)
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
// 关闭
|
||||
const handleClose = () => {
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
// 监听对话框打开,初始化数据
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val) {
|
||||
headerList.value = parseHeaders(props.headers)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.header-editor {
|
||||
.header-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.header-key {
|
||||
flex: 0 0 180px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-value {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,11 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-form-item label="执行类型" key="executeType">
|
||||
<el-select v-model="serviceTaskForm.executeType">
|
||||
<el-select v-model="serviceTaskForm.executeType" @change="handleExecuteTypeChange">
|
||||
<el-option label="Java类" value="class" />
|
||||
<el-option label="表达式" value="expression" />
|
||||
<el-option label="代理表达式" value="delegateExpression" />
|
||||
<el-option label="HTTP 调用" value="http" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
@@ -29,48 +30,345 @@
|
||||
prop="delegateExpression"
|
||||
key="execute-delegate"
|
||||
>
|
||||
<el-input v-model="serviceTaskForm.delegateExpression" clearable @change="updateElementTask" />
|
||||
<el-input
|
||||
v-model="serviceTaskForm.delegateExpression"
|
||||
clearable
|
||||
@change="updateElementTask"
|
||||
/>
|
||||
</el-form-item>
|
||||
<template v-if="serviceTaskForm.executeType === 'http'">
|
||||
<el-form-item label="请求方法" key="http-method">
|
||||
<el-radio-group v-model="httpTaskForm.requestMethod">
|
||||
<el-radio-button label="GET" value="GET" />
|
||||
<el-radio-button label="POST" value="POST" />
|
||||
<el-radio-button label="PUT" value="PUT" />
|
||||
<el-radio-button label="DELETE" value="DELETE" />
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="请求地址" key="http-url" prop="requestUrl">
|
||||
<el-input v-model="httpTaskForm.requestUrl" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="请求头" key="http-headers">
|
||||
<div style="display: flex; gap: 8px; align-items: flex-start; width: 100%">
|
||||
<el-input
|
||||
v-model="httpTaskForm.requestHeaders"
|
||||
type="textarea"
|
||||
resize="vertical"
|
||||
:autosize="{ minRows: 4, maxRows: 8 }"
|
||||
readonly
|
||||
placeholder="点击右侧编辑按钮添加请求头"
|
||||
style="flex: 1; min-width: 0"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Edit"
|
||||
@click="showHeaderEditor = true"
|
||||
style="flex-shrink: 0"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="禁止重定向" key="http-disallow-redirects">
|
||||
<el-switch v-model="httpTaskForm.disallowRedirects" />
|
||||
</el-form-item>
|
||||
<el-form-item label="忽略异常" key="http-ignore-exception">
|
||||
<el-switch v-model="httpTaskForm.ignoreException" />
|
||||
</el-form-item>
|
||||
<el-form-item label="保存返回变量" key="http-save-response">
|
||||
<el-switch v-model="httpTaskForm.saveResponseParameters" />
|
||||
</el-form-item>
|
||||
<el-form-item label="是否瞬间变量" key="http-save-transient">
|
||||
<el-switch v-model="httpTaskForm.saveResponseParametersTransient" />
|
||||
</el-form-item>
|
||||
<el-form-item label="返回变量前缀" key="http-result-variable-prefix">
|
||||
<el-input v-model="httpTaskForm.resultVariablePrefix" />
|
||||
</el-form-item>
|
||||
<el-form-item label="格式化返回为JSON" key="http-save-json">
|
||||
<el-switch v-model="httpTaskForm.saveResponseVariableAsJson" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 请求头编辑器 -->
|
||||
<HttpHeaderEditor
|
||||
v-model="showHeaderEditor"
|
||||
:headers="httpTaskForm.requestHeaders"
|
||||
@save="handleHeadersSave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Edit } from '@element-plus/icons-vue'
|
||||
import { updateElementExtensions } from '@/components/bpmnProcessDesigner/package/utils'
|
||||
import HttpHeaderEditor from './HttpHeaderEditor.vue'
|
||||
|
||||
defineOptions({ name: 'ServiceTask' })
|
||||
const props = defineProps({
|
||||
id: String,
|
||||
type: String
|
||||
})
|
||||
|
||||
const defaultTaskForm = ref({
|
||||
const prefix = (inject('prefix', 'flowable') || 'flowable') as string
|
||||
const flowableTypeKey = `${prefix}:type`
|
||||
const flowableFieldType = `${prefix}:Field`
|
||||
|
||||
const HTTP_FIELD_NAMES = [
|
||||
'requestMethod',
|
||||
'requestUrl',
|
||||
'requestHeaders',
|
||||
'disallowRedirects',
|
||||
'ignoreException',
|
||||
'saveResponseParameters',
|
||||
'resultVariablePrefix',
|
||||
'saveResponseParametersTransient',
|
||||
'saveResponseVariableAsJson'
|
||||
]
|
||||
const HTTP_BOOLEAN_FIELDS = new Set([
|
||||
'disallowRedirects',
|
||||
'ignoreException',
|
||||
'saveResponseParameters',
|
||||
'saveResponseParametersTransient',
|
||||
'saveResponseVariableAsJson'
|
||||
])
|
||||
|
||||
const DEFAULT_TASK_FORM = {
|
||||
executeType: '',
|
||||
class: '',
|
||||
expression: '',
|
||||
delegateExpression: ''
|
||||
})
|
||||
}
|
||||
|
||||
const serviceTaskForm = ref<any>({})
|
||||
const DEFAULT_HTTP_FORM = {
|
||||
requestMethod: 'GET',
|
||||
requestUrl: '',
|
||||
requestHeaders: 'Content-Type: application/json',
|
||||
resultVariablePrefix: '',
|
||||
disallowRedirects: false,
|
||||
ignoreException: false,
|
||||
saveResponseParameters: false,
|
||||
saveResponseParametersTransient: false,
|
||||
saveResponseVariableAsJson: false
|
||||
}
|
||||
|
||||
const serviceTaskForm = ref({ ...DEFAULT_TASK_FORM })
|
||||
const httpTaskForm = ref({ ...DEFAULT_HTTP_FORM })
|
||||
const bpmnElement = ref()
|
||||
const httpInitializing = ref(false)
|
||||
const showHeaderEditor = ref(false)
|
||||
|
||||
const bpmnInstances = () => (window as any)?.bpmnInstances
|
||||
|
||||
const resetTaskForm = () => {
|
||||
for (let key in defaultTaskForm.value) {
|
||||
let value = bpmnElement.value?.businessObject[key] || defaultTaskForm.value[key]
|
||||
serviceTaskForm.value[key] = value
|
||||
if (value) {
|
||||
serviceTaskForm.value.executeType = key
|
||||
// 判断字符串是否包含表达式
|
||||
const isExpression = (value: string): boolean => {
|
||||
if (!value) return false
|
||||
// 检测 ${...} 或 #{...} 格式的表达式
|
||||
return /\${[^}]+}/.test(value) || /#{[^}]+}/.test(value)
|
||||
}
|
||||
|
||||
const collectHttpExtensionInfo = () => {
|
||||
const businessObject = bpmnElement.value?.businessObject
|
||||
const extensionElements = businessObject?.extensionElements
|
||||
const httpFields = new Map<string, string>()
|
||||
const httpFieldTypes = new Map<string, 'string' | 'expression'>()
|
||||
const otherExtensions: any[] = []
|
||||
|
||||
extensionElements?.values?.forEach((item: any) => {
|
||||
if (item?.$type === flowableFieldType && HTTP_FIELD_NAMES.includes(item.name)) {
|
||||
const value = item.string ?? item.stringValue ?? item.expression ?? ''
|
||||
const fieldType = item.expression ? 'expression' : 'string'
|
||||
httpFields.set(item.name, value)
|
||||
httpFieldTypes.set(item.name, fieldType)
|
||||
} else {
|
||||
otherExtensions.push(item)
|
||||
}
|
||||
})
|
||||
|
||||
return { httpFields, httpFieldTypes, otherExtensions }
|
||||
}
|
||||
|
||||
const resetHttpDefaults = () => {
|
||||
httpInitializing.value = true
|
||||
httpTaskForm.value = { ...DEFAULT_HTTP_FORM }
|
||||
nextTick(() => {
|
||||
httpInitializing.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const resetHttpForm = () => {
|
||||
httpInitializing.value = true
|
||||
const { httpFields } = collectHttpExtensionInfo()
|
||||
const nextForm = { ...DEFAULT_HTTP_FORM }
|
||||
|
||||
HTTP_FIELD_NAMES.forEach((name) => {
|
||||
const stored = httpFields.get(name)
|
||||
if (stored !== undefined) {
|
||||
nextForm[name] = HTTP_BOOLEAN_FIELDS.has(name) ? stored === 'true' : stored
|
||||
}
|
||||
})
|
||||
|
||||
httpTaskForm.value = nextForm
|
||||
nextTick(() => {
|
||||
httpInitializing.value = false
|
||||
updateHttpExtensions(true)
|
||||
})
|
||||
}
|
||||
|
||||
const resetServiceTaskForm = () => {
|
||||
const businessObject = bpmnElement.value?.businessObject
|
||||
const nextForm = { ...DEFAULT_TASK_FORM }
|
||||
|
||||
if (businessObject) {
|
||||
if (businessObject.class) {
|
||||
nextForm.class = businessObject.class
|
||||
nextForm.executeType = 'class'
|
||||
}
|
||||
if (businessObject.expression) {
|
||||
nextForm.expression = businessObject.expression
|
||||
nextForm.executeType = 'expression'
|
||||
}
|
||||
if (businessObject.delegateExpression) {
|
||||
nextForm.delegateExpression = businessObject.delegateExpression
|
||||
nextForm.executeType = 'delegateExpression'
|
||||
}
|
||||
if (businessObject.$attrs?.[flowableTypeKey] === 'http') {
|
||||
nextForm.executeType = 'http'
|
||||
} else {
|
||||
// 兜底:如缺少 flowable:type=http,但扩展里已有 HTTP 的字段,也认为是 HTTP
|
||||
const { httpFields } = collectHttpExtensionInfo()
|
||||
if (httpFields.size > 0) {
|
||||
nextForm.executeType = 'http'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serviceTaskForm.value = nextForm
|
||||
|
||||
if (nextForm.executeType === 'http') {
|
||||
resetHttpForm()
|
||||
} else {
|
||||
resetHttpDefaults()
|
||||
}
|
||||
}
|
||||
|
||||
const updateElementTask = () => {
|
||||
let taskAttr = Object.create(null);
|
||||
const type = serviceTaskForm.value.executeType;
|
||||
for (let key in serviceTaskForm.value) {
|
||||
if (key !== 'executeType' && key !== type) taskAttr[key] = null;
|
||||
const shouldPersistField = (name: string, value: any) => {
|
||||
if (HTTP_BOOLEAN_FIELDS.has(name)) return true
|
||||
if (name === 'requestMethod') return true
|
||||
if (name === 'requestUrl') return !!value
|
||||
return value !== undefined && value !== ''
|
||||
}
|
||||
|
||||
const updateHttpExtensions = (force = false) => {
|
||||
if (!bpmnElement.value) return
|
||||
if (!force && (httpInitializing.value || serviceTaskForm.value.executeType !== 'http')) {
|
||||
return
|
||||
}
|
||||
taskAttr[type] = serviceTaskForm.value[type] || "";
|
||||
|
||||
const {
|
||||
httpFields: existingFields,
|
||||
httpFieldTypes: existingTypes,
|
||||
otherExtensions
|
||||
} = collectHttpExtensionInfo()
|
||||
|
||||
const desiredEntries: [string, string][] = []
|
||||
HTTP_FIELD_NAMES.forEach((name) => {
|
||||
const rawValue = httpTaskForm.value[name]
|
||||
if (!shouldPersistField(name, rawValue)) {
|
||||
return
|
||||
}
|
||||
|
||||
const persisted = HTTP_BOOLEAN_FIELDS.has(name)
|
||||
? String(!!rawValue)
|
||||
: rawValue === undefined
|
||||
? ''
|
||||
: String(rawValue)
|
||||
|
||||
desiredEntries.push([name, persisted])
|
||||
})
|
||||
|
||||
// 检查是否有变化:不仅比较值,还要比较字段类型(string vs expression)
|
||||
if (!force && desiredEntries.length === existingFields.size) {
|
||||
let noChange = true
|
||||
for (const [name, value] of desiredEntries) {
|
||||
const existingValue = existingFields.get(name)
|
||||
const existingType = existingTypes.get(name)
|
||||
const currentType = isExpression(value) ? 'expression' : 'string'
|
||||
if (existingValue !== value || existingType !== currentType) {
|
||||
noChange = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (noChange) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const moddle = bpmnInstances().moddle
|
||||
const httpFieldElements = desiredEntries.map(([name, value]) => {
|
||||
// 根据值是否包含表达式来决定使用 string 还是 expression 属性
|
||||
const isExpr = isExpression(value)
|
||||
return moddle.create(flowableFieldType, {
|
||||
name,
|
||||
...(isExpr ? { expression: value } : { string: value })
|
||||
})
|
||||
})
|
||||
|
||||
updateElementExtensions(bpmnElement.value, [...otherExtensions, ...httpFieldElements])
|
||||
}
|
||||
|
||||
const removeHttpExtensions = () => {
|
||||
if (!bpmnElement.value) return
|
||||
const { httpFields, otherExtensions } = collectHttpExtensionInfo()
|
||||
if (!httpFields.size) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!otherExtensions.length) {
|
||||
bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), {
|
||||
extensionElements: null
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
updateElementExtensions(bpmnElement.value, otherExtensions)
|
||||
}
|
||||
|
||||
const updateElementTask = () => {
|
||||
if (!bpmnElement.value) return
|
||||
|
||||
const taskAttr: Record<string, any> = {
|
||||
class: null,
|
||||
expression: null,
|
||||
delegateExpression: null,
|
||||
[flowableTypeKey]: null
|
||||
}
|
||||
|
||||
const type = serviceTaskForm.value.executeType
|
||||
if (type === 'class' || type === 'expression' || type === 'delegateExpression') {
|
||||
taskAttr[type] = serviceTaskForm.value[type] || null
|
||||
} else if (type === 'http') {
|
||||
taskAttr[flowableTypeKey] = 'http'
|
||||
}
|
||||
|
||||
bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), taskAttr)
|
||||
|
||||
if (type === 'http') {
|
||||
updateHttpExtensions(true)
|
||||
} else {
|
||||
removeHttpExtensions()
|
||||
}
|
||||
}
|
||||
|
||||
const handleExecuteTypeChange = (value: string) => {
|
||||
serviceTaskForm.value.executeType = value
|
||||
if (value === 'http') {
|
||||
resetHttpForm()
|
||||
}
|
||||
updateElementTask()
|
||||
}
|
||||
|
||||
const handleHeadersSave = (headersStr: string) => {
|
||||
httpTaskForm.value.requestHeaders = headersStr
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -82,10 +380,17 @@ watch(
|
||||
() => {
|
||||
bpmnElement.value = bpmnInstances().bpmnElement
|
||||
nextTick(() => {
|
||||
resetTaskForm()
|
||||
resetServiceTaskForm()
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => httpTaskForm.value,
|
||||
() => {
|
||||
updateHttpExtensions()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { toRaw } from 'vue'
|
||||
const bpmnInstances = () => (window as any)?.bpmnInstances
|
||||
// 创建监听器实例
|
||||
export function createListenerObject(options, isTask, prefix) {
|
||||
@@ -61,7 +60,8 @@ export function updateElementExtensions(element, extensionList) {
|
||||
const extensions = bpmnInstances().moddle.create('bpmn:ExtensionElements', {
|
||||
values: extensionList
|
||||
})
|
||||
bpmnInstances().modeling.updateProperties(toRaw(element), {
|
||||
// 直接使用原始元素对象,不需要toRaw包装
|
||||
bpmnInstances().modeling.updateProperties(element, {
|
||||
extensionElements: extensions
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import errorCode from './errorCode'
|
||||
|
||||
import { resetRouter } from '@/router'
|
||||
import { deleteUserCache } from '@/hooks/web/useCache'
|
||||
import { ApiEncrypt } from '@/utils/encrypt'
|
||||
|
||||
const tenantEnable = import.meta.env.VITE_APP_TENANT_ENABLE
|
||||
const { result_code, base_url, request_timeout } = config
|
||||
@@ -83,6 +84,20 @@ service.interceptors.request.use(
|
||||
}
|
||||
}
|
||||
}
|
||||
// 是否 API 加密
|
||||
if ((config!.headers || {}).isEncrypt && !(config!.headers || {}).isEncrypted) {
|
||||
try {
|
||||
// 加密请求数据
|
||||
if (config.data) {
|
||||
config.data = ApiEncrypt.encryptRequest(config.data)
|
||||
// 设置加密标识头
|
||||
config.headers[ApiEncrypt.getEncryptHeader()] = 'true'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('请求数据加密失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
@@ -101,6 +116,22 @@ service.interceptors.response.use(
|
||||
// 返回“[HTTP]请求没有返回值”;
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
// 检查是否需要解密响应数据
|
||||
const encryptHeader = ApiEncrypt.getEncryptHeader()
|
||||
const isEncryptResponse =
|
||||
response.headers[encryptHeader] === 'true' ||
|
||||
response.headers[encryptHeader.toLowerCase()] === 'true'
|
||||
if (isEncryptResponse && typeof data === 'string') {
|
||||
try {
|
||||
// 解密响应数据
|
||||
data = ApiEncrypt.decryptResponse(data)
|
||||
} catch (error) {
|
||||
console.error('响应数据解密失败:', error)
|
||||
throw new Error('响应数据解密失败: ' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
// 未设置状态码则默认成功状态
|
||||
// 二进制数据则直接返回,例如说 Excel 导出
|
||||
@@ -138,6 +169,9 @@ service.interceptors.response.use(
|
||||
cb()
|
||||
})
|
||||
requestList = []
|
||||
if ((config!.headers || {}).isEncrypt){
|
||||
(config!.headers || {}).isEncrypted = true
|
||||
}
|
||||
return service(config)
|
||||
} catch (e) {
|
||||
// 为什么需要 catch 异常呢?刷新失败时,请求因为 Promise.reject 触发异常。
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
import { formatDate } from '@/utils/formatTime'
|
||||
import * as NotifyMessageApi from '@/api/system/notify/message'
|
||||
import { useUserStoreWithOut } from '@/store/modules/user'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
|
||||
defineOptions({ name: 'Message' })
|
||||
|
||||
defineProps({
|
||||
color: propTypes.string.def('')
|
||||
})
|
||||
|
||||
const { push } = useRouter()
|
||||
const userStore = useUserStoreWithOut()
|
||||
const activeName = ref('notice')
|
||||
@@ -54,7 +59,7 @@ onMounted(() => {
|
||||
<ElPopover :width="400" placement="bottom" trigger="click">
|
||||
<template #reference>
|
||||
<ElBadge :is-dot="unreadCount > 0" class="item">
|
||||
<Icon :size="18" class="cursor-pointer" icon="ep:bell" @click="getList" />
|
||||
<Icon :size="18" class="cursor-pointer" icon="ep:bell" :color="color" @click="getList" />
|
||||
</ElBadge>
|
||||
</template>
|
||||
<ElTabs v-model="activeName">
|
||||
|
||||
@@ -109,6 +109,7 @@ watch(
|
||||
// 拷贝
|
||||
const copyConfig = async () => {
|
||||
const { copy, copied, isSupported } = useClipboard({
|
||||
legacy: true,
|
||||
source: `
|
||||
// 面包屑
|
||||
breadcrumb: ${appStore.getBreadcrumb},
|
||||
@@ -296,7 +297,7 @@ const clear = () => {
|
||||
$prefix-cls: #{$namespace}-setting;
|
||||
|
||||
.#{$prefix-cls} {
|
||||
z-index: 1200; /* 修正没有z-index会被表格层覆盖,值不要超过4000 */
|
||||
border-radius: 6px 0 0 6px;
|
||||
z-index: 1200;/*修正没有z-index会被表格层覆盖,值不要超过4000*/
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -255,6 +255,15 @@ const canShowIcon = (item: RouteLocationNormalizedLoaded) => {
|
||||
return false
|
||||
}
|
||||
|
||||
const closeTabOnMouseMidClick = (e: MouseEvent, item) => {
|
||||
// 中键:button === 1
|
||||
if (e.button === 1) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
closeSelectedTag(item)
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
initTags()
|
||||
addTags()
|
||||
@@ -293,6 +302,7 @@ watch(
|
||||
v-for="item in visitedViews"
|
||||
:key="item.fullPath"
|
||||
:ref="itemRefs.set"
|
||||
@auxclick="closeTabOnMouseMidClick($event, item)"
|
||||
:class="[
|
||||
`${prefixCls}__item`,
|
||||
tagsViewImmerse ? `${prefixCls}__item--immerse` : '',
|
||||
|
||||
@@ -73,7 +73,7 @@ export default defineComponent({
|
||||
{screenfull.value ? (
|
||||
<Screenfull class="custom-hover" color="var(--top-header-text-color)"></Screenfull>
|
||||
) : undefined}
|
||||
{search.value ? <RouterSearch isModal={false} /> : undefined}
|
||||
{search.value ? <RouterSearch isModal={false} color="var(--top-header-text-color)"/> : undefined}
|
||||
{size.value ? (
|
||||
<SizeDropdown class="custom-hover" color="var(--top-header-text-color)"></SizeDropdown>
|
||||
) : undefined}
|
||||
|
||||
@@ -146,9 +146,11 @@ export default {
|
||||
invalidTenantName:"Invalid Tenant Name"
|
||||
},
|
||||
captcha: {
|
||||
verify: 'Verify',
|
||||
verification: 'Please complete security verification',
|
||||
slide: 'Swipe right to complete verification',
|
||||
point: 'Please click',
|
||||
code: 'Please enter the verification code',
|
||||
success: 'Verification succeeded',
|
||||
fail: 'verification failed'
|
||||
},
|
||||
@@ -457,4 +459,4 @@ export default {
|
||||
btn_zoom_out: 'Zoom out',
|
||||
preview: 'Preivew'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,9 +147,11 @@ export default {
|
||||
invalidTenantName: '无效的租户名称'
|
||||
},
|
||||
captcha: {
|
||||
verify: '验证',
|
||||
verification: '请完成安全验证',
|
||||
slide: '向右滑动完成验证',
|
||||
point: '请依次点击',
|
||||
code: '请输入验证码',
|
||||
success: '验证成功',
|
||||
fail: '验证失败'
|
||||
},
|
||||
@@ -453,4 +455,4 @@ export default {
|
||||
preview: '预览'
|
||||
},
|
||||
'OAuth 2.0': 'OAuth 2.0' // 避免菜单名是 OAuth 2.0 时,一直 warn 报错
|
||||
}
|
||||
}
|
||||
11
src/main.ts
11
src/main.ts
@@ -42,6 +42,11 @@ import Logger from '@/utils/Logger'
|
||||
|
||||
import VueDOMPurifyHTML from 'vue-dompurify-html' // 解决v-html 的安全隐患
|
||||
|
||||
// wangEditor 插件注册
|
||||
import { setupWangEditorPlugin } from '@/views/bpm/model/form/PrintTemplate'
|
||||
|
||||
import print from 'vue3-print-nb' // 打印插件
|
||||
|
||||
// 创建实例
|
||||
const setupAll = async () => {
|
||||
const app = createApp(App)
|
||||
@@ -62,10 +67,16 @@ const setupAll = async () => {
|
||||
setupAuth(app)
|
||||
setupMountedFocus(app)
|
||||
|
||||
// wangEditor 插件注册
|
||||
setupWangEditorPlugin()
|
||||
|
||||
await router.isReady()
|
||||
|
||||
app.use(VueDOMPurifyHTML)
|
||||
|
||||
// 打印
|
||||
app.use(print)
|
||||
|
||||
app.mount('#app')
|
||||
}
|
||||
|
||||
|
||||
@@ -64,12 +64,13 @@ router.beforeEach(async (to, from, next) => {
|
||||
if (to.path === '/login') {
|
||||
next({ path: '/' })
|
||||
} else {
|
||||
// 获取所有字典
|
||||
const dictStore = useDictStoreWithOut()
|
||||
const userStore = useUserStoreWithOut()
|
||||
const permissionStore = usePermissionStoreWithOut()
|
||||
// 异步加载字典
|
||||
// 另外,间接 issue:https://gitee.com/yudaocode/yudao-ui-admin-vue3/issues/ID9FLI
|
||||
if (!dictStore.getIsSetDict) {
|
||||
await dictStore.setDictMap()
|
||||
dictStore.setDictMap().then()
|
||||
}
|
||||
if (!userStore.getIsSetUser) {
|
||||
isRelogin.show = true
|
||||
|
||||
@@ -68,6 +68,7 @@ import { UploadFile, UploadImg, UploadImgs } from '@/components/UploadFile'
|
||||
import { useApiSelect } from '@/components/FormCreate'
|
||||
import { Editor } from '@/components/Editor'
|
||||
import DictSelect from '@/components/FormCreate/src/components/DictSelect.vue'
|
||||
import DeptSelect from '@/components/FormCreate/src/components/DeptSelect.vue'
|
||||
|
||||
const UserSelect = useApiSelect({
|
||||
name: 'UserSelect',
|
||||
@@ -75,12 +76,6 @@ const UserSelect = useApiSelect({
|
||||
valueField: 'id',
|
||||
url: '/system/user/simple-list'
|
||||
})
|
||||
const DeptSelect = useApiSelect({
|
||||
name: 'DeptSelect',
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
url: '/system/dept/simple-list'
|
||||
})
|
||||
const ApiSelect = useApiSelect({
|
||||
name: 'ApiSelect'
|
||||
})
|
||||
|
||||
@@ -8,7 +8,15 @@ const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.VITE_BASE_PATH), // createWebHashHistory URL带#,createWebHistory URL不带#
|
||||
strict: true,
|
||||
routes: remainingRouter as RouteRecordRaw[],
|
||||
scrollBehavior: () => ({ left: 0, top: 0 })
|
||||
scrollBehavior: () => {
|
||||
// 新开标签时、返回标签时,滚动条回到顶部,否则会保留上次标签的滚动位置。
|
||||
const scrollbarWrap = document.querySelector('.v-layout-content-scrollbar .el-scrollbar__wrap')
|
||||
if (scrollbarWrap) {
|
||||
// scrollbarWrap.scrollTo({ left: 0, top: 0, behavior: 'auto' })
|
||||
scrollbarWrap.scrollTop = 0
|
||||
}
|
||||
return { left: 0, top: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
export const resetRouter = (): void => {
|
||||
|
||||
@@ -46,6 +46,9 @@ export const useDictStore = defineStore('dict', {
|
||||
this.isSetDict = true
|
||||
} else {
|
||||
const res = await getSimpleDictDataList()
|
||||
if (!res || res.length === 0) {
|
||||
return
|
||||
}
|
||||
// 设置数据
|
||||
const dictDataMap = new Map<string, any>()
|
||||
res.forEach((dictData: DictDataVO) => {
|
||||
@@ -76,6 +79,9 @@ export const useDictStore = defineStore('dict', {
|
||||
async resetDict() {
|
||||
wsCache.delete(CACHE_KEY.DICT_CACHE)
|
||||
const res = await getSimpleDictDataList()
|
||||
if (!res || res.length === 0) {
|
||||
return
|
||||
}
|
||||
// 设置数据
|
||||
const dictDataMap = new Map<string, any>()
|
||||
res.forEach((dictData: DictDataVO) => {
|
||||
|
||||
@@ -93,6 +93,12 @@ export const useTagsViewStore = defineStore('tagsView', {
|
||||
delCachedView() {
|
||||
const route = router.currentRoute.value
|
||||
const index = findIndex<string>(this.getCachedViews, (v) => v === route.name)
|
||||
// 需要注释,解决“标签页刷新无效”。相关案例:https://github.com/yudaocode/yudao-ui-admin-vue3/issues/180
|
||||
// for (const v of this.visitedViews) {
|
||||
// if (v.name === route.name) {
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
if (index > -1) {
|
||||
this.cachedViews.delete(this.getCachedViews[index])
|
||||
}
|
||||
|
||||
@@ -227,12 +227,14 @@ export enum DICT_TYPE {
|
||||
AI_WRITE_FORMAT = 'ai_write_format', // AI 写作格式
|
||||
AI_WRITE_TONE = 'ai_write_tone', // AI 写作语气
|
||||
AI_WRITE_LANGUAGE = 'ai_write_language', // AI 写作语言
|
||||
AI_MCP_CLIENT_NAME = 'ai_mcp_client_name', // AI MCP Client 名字
|
||||
|
||||
// ========== IOT - 物联网模块 ==========
|
||||
IOT_NET_TYPE = 'iot_net_type', // IOT 联网方式
|
||||
IOT_PRODUCT_STATUS = 'iot_product_status', // IOT 产品状态
|
||||
IOT_PRODUCT_DEVICE_TYPE = 'iot_product_device_type', // IOT 产品设备类型
|
||||
IOT_CODEC_TYPE = 'iot_codec_type', // IOT 数据格式(编解码器类型)
|
||||
IOT_PROTOCOL_TYPE = 'iot_protocol_type', // IOT 协议类型
|
||||
IOT_SERIALIZE_TYPE = 'iot_serialize_type', // IOT 序列化类型
|
||||
IOT_LOCATION_TYPE = 'iot_location_type', // IOT 定位类型
|
||||
IOT_DEVICE_STATE = 'iot_device_state', // IOT 设备状态
|
||||
IOT_THING_MODEL_TYPE = 'iot_thing_model_type', // IOT 产品功能类型
|
||||
|
||||
231
src/utils/encrypt.ts
Normal file
231
src/utils/encrypt.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import CryptoJS from 'crypto-js'
|
||||
import { JSEncrypt } from 'jsencrypt'
|
||||
|
||||
/**
|
||||
* API 加解密工具类
|
||||
* 支持 AES 和 RSA 加密算法
|
||||
*/
|
||||
|
||||
// 从环境变量获取配置
|
||||
const API_ENCRYPT_ENABLE = import.meta.env.VITE_APP_API_ENCRYPT_ENABLE === 'true'
|
||||
const API_ENCRYPT_HEADER = import.meta.env.VITE_APP_API_ENCRYPT_HEADER || 'X-Api-Encrypt'
|
||||
const API_ENCRYPT_ALGORITHM = import.meta.env.VITE_APP_API_ENCRYPT_ALGORITHM || 'AES'
|
||||
const API_ENCRYPT_REQUEST_KEY = import.meta.env.VITE_APP_API_ENCRYPT_REQUEST_KEY || '' // AES密钥 或 RSA公钥
|
||||
const API_ENCRYPT_RESPONSE_KEY = import.meta.env.VITE_APP_API_ENCRYPT_RESPONSE_KEY || '' // AES密钥 或 RSA私钥
|
||||
|
||||
/**
|
||||
* AES 加密工具类
|
||||
*/
|
||||
export class AES {
|
||||
/**
|
||||
* AES 加密
|
||||
* @param data 要加密的数据
|
||||
* @param key 加密密钥
|
||||
* @returns 加密后的字符串
|
||||
*/
|
||||
static encrypt(data: string, key: string): string {
|
||||
try {
|
||||
if (!key) {
|
||||
throw new Error('AES 加密密钥不能为空')
|
||||
}
|
||||
if (key.length !== 32) {
|
||||
throw new Error(`AES 加密密钥长度必须为 32 位,当前长度: ${key.length}`)
|
||||
}
|
||||
|
||||
const keyUtf8 = CryptoJS.enc.Utf8.parse(key)
|
||||
const encrypted = CryptoJS.AES.encrypt(data, keyUtf8, {
|
||||
mode: CryptoJS.mode.ECB,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
})
|
||||
return encrypted.toString()
|
||||
} catch (error) {
|
||||
console.error('AES 加密失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AES 解密
|
||||
* @param encryptedData 加密的数据
|
||||
* @param key 解密密钥
|
||||
* @returns 解密后的字符串
|
||||
*/
|
||||
static decrypt(encryptedData: string, key: string): string {
|
||||
try {
|
||||
if (!key) {
|
||||
throw new Error('AES 解密密钥不能为空')
|
||||
}
|
||||
if (key.length !== 32) {
|
||||
throw new Error(`AES 解密密钥长度必须为 32 位,当前长度: ${key.length}`)
|
||||
}
|
||||
if (!encryptedData) {
|
||||
throw new Error('AES 解密数据不能为空')
|
||||
}
|
||||
|
||||
const keyUtf8 = CryptoJS.enc.Utf8.parse(key)
|
||||
const decrypted = CryptoJS.AES.decrypt(encryptedData, keyUtf8, {
|
||||
mode: CryptoJS.mode.ECB,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
})
|
||||
const result = decrypted.toString(CryptoJS.enc.Utf8)
|
||||
if (!result) {
|
||||
throw new Error('AES 解密结果为空,可能是密钥错误或数据损坏')
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('AES 解密失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA 加密工具类
|
||||
*/
|
||||
export class RSA {
|
||||
/**
|
||||
* RSA 加密
|
||||
* @param data 要加密的数据
|
||||
* @param publicKey 公钥(必需)
|
||||
* @returns 加密后的字符串
|
||||
*/
|
||||
static encrypt(data: string, publicKey: string): string | false {
|
||||
try {
|
||||
if (!publicKey) {
|
||||
throw new Error('RSA 公钥不能为空')
|
||||
}
|
||||
|
||||
const encryptor = new JSEncrypt()
|
||||
encryptor.setPublicKey(publicKey)
|
||||
const result = encryptor.encrypt(data)
|
||||
if (result === false) {
|
||||
throw new Error('RSA 加密失败,可能是公钥格式错误或数据过长')
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('RSA 加密失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA 解密
|
||||
* @param encryptedData 加密的数据
|
||||
* @param privateKey 私钥(必需)
|
||||
* @returns 解密后的字符串
|
||||
*/
|
||||
static decrypt(encryptedData: string, privateKey: string): string | false {
|
||||
try {
|
||||
if (!privateKey) {
|
||||
throw new Error('RSA 私钥不能为空')
|
||||
}
|
||||
if (!encryptedData) {
|
||||
throw new Error('RSA 解密数据不能为空')
|
||||
}
|
||||
|
||||
const encryptor = new JSEncrypt()
|
||||
encryptor.setPrivateKey(privateKey)
|
||||
const result = encryptor.decrypt(encryptedData)
|
||||
if (result === false) {
|
||||
throw new Error('RSA 解密失败,可能是私钥错误或数据损坏')
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('RSA 解密失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 加解密主类
|
||||
*/
|
||||
export class ApiEncrypt {
|
||||
/**
|
||||
* 获取加密头名称
|
||||
*/
|
||||
static getEncryptHeader(): string {
|
||||
return API_ENCRYPT_HEADER
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密请求数据
|
||||
* @param data 要加密的数据
|
||||
* @returns 加密后的数据
|
||||
*/
|
||||
static encryptRequest(data: any): string {
|
||||
if (!API_ENCRYPT_ENABLE) {
|
||||
return data
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonData = typeof data === 'string' ? data : JSON.stringify(data)
|
||||
|
||||
if (API_ENCRYPT_ALGORITHM.toUpperCase() === 'AES') {
|
||||
if (!API_ENCRYPT_REQUEST_KEY) {
|
||||
throw new Error('AES 请求加密密钥未配置')
|
||||
}
|
||||
return AES.encrypt(jsonData, API_ENCRYPT_REQUEST_KEY)
|
||||
} else if (API_ENCRYPT_ALGORITHM.toUpperCase() === 'RSA') {
|
||||
if (!API_ENCRYPT_REQUEST_KEY) {
|
||||
throw new Error('RSA 公钥未配置')
|
||||
}
|
||||
const result = RSA.encrypt(jsonData, API_ENCRYPT_REQUEST_KEY)
|
||||
if (result === false) {
|
||||
throw new Error('RSA 加密失败')
|
||||
}
|
||||
return result
|
||||
} else {
|
||||
throw new Error(`不支持的加密算法: ${API_ENCRYPT_ALGORITHM}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('请求数据加密失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密响应数据
|
||||
* @param encryptedData 加密的响应数据
|
||||
* @returns 解密后的数据
|
||||
*/
|
||||
static decryptResponse(encryptedData: string): any {
|
||||
if (!API_ENCRYPT_ENABLE) {
|
||||
return encryptedData
|
||||
}
|
||||
|
||||
try {
|
||||
let decryptedData: string | false = ''
|
||||
if (API_ENCRYPT_ALGORITHM.toUpperCase() === 'AES') {
|
||||
if (!API_ENCRYPT_RESPONSE_KEY) {
|
||||
throw new Error('AES 响应解密密钥未配置')
|
||||
}
|
||||
decryptedData = AES.decrypt(encryptedData, API_ENCRYPT_RESPONSE_KEY)
|
||||
} else if (API_ENCRYPT_ALGORITHM.toUpperCase() === 'RSA') {
|
||||
if (!API_ENCRYPT_RESPONSE_KEY) {
|
||||
throw new Error('RSA 私钥未配置')
|
||||
}
|
||||
decryptedData = RSA.decrypt(encryptedData, API_ENCRYPT_RESPONSE_KEY)
|
||||
if (decryptedData === false) {
|
||||
throw new Error('RSA 解密失败')
|
||||
}
|
||||
} else {
|
||||
throw new Error(`不支持的解密算法: ${API_ENCRYPT_ALGORITHM}`)
|
||||
}
|
||||
|
||||
if (!decryptedData) {
|
||||
throw new Error('解密结果为空')
|
||||
}
|
||||
|
||||
// 尝试解析为 JSON,如果失败则返回原字符串
|
||||
try {
|
||||
return JSON.parse(decryptedData)
|
||||
} catch {
|
||||
return decryptedData
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('响应数据解密失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/utils/file.ts
Normal file
37
src/utils/file.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/** 从 URL 中提取文件名 */
|
||||
export const getFileNameFromUrl = (url: string): string => {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const pathname = urlObj.pathname
|
||||
const fileName = pathname.split('/').pop() || 'unknown'
|
||||
return decodeURIComponent(fileName)
|
||||
} catch {
|
||||
// 如果 URL 解析失败,尝试从字符串中提取
|
||||
const parts = url.split('/')
|
||||
return parts[parts.length - 1] || 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断是否为图片 */
|
||||
export const isImage = (filename: string): boolean => {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext)
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
/** 获取文件图标 */
|
||||
export const getFileIcon = (filename: string): string => {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
if (isImage(ext)) {
|
||||
return 'ep:picture'
|
||||
}
|
||||
return 'ep:document'
|
||||
}
|
||||
@@ -2,42 +2,49 @@
|
||||
* 针对 https://github.com/xaboy/form-create-designer 封装的工具类
|
||||
*/
|
||||
import { isRef } from 'vue'
|
||||
import formCreate from '@form-create/element-ui'
|
||||
|
||||
// 编码表单 Conf
|
||||
/** 编码表单 Conf */
|
||||
export const encodeConf = (designerRef: object) => {
|
||||
// @ts-ignore
|
||||
return JSON.stringify(designerRef.value.getOption())
|
||||
// 关联案例:https://gitee.com/yudaocode/yudao-ui-admin-vue3/pulls/834/
|
||||
return formCreate.toJson(designerRef.value.getOption())
|
||||
}
|
||||
|
||||
// 编码表单 Fields
|
||||
/** 解码表单 Conf */
|
||||
export const decodeConf = (conf: string) => {
|
||||
return formCreate.parseJson(conf)
|
||||
}
|
||||
|
||||
/** 编码表单 Fields */
|
||||
export const encodeFields = (designerRef: object) => {
|
||||
// @ts-ignore
|
||||
const rule = JSON.parse(designerRef.value.getJson())
|
||||
const rule = designerRef.value.getRule()
|
||||
const fields: string[] = []
|
||||
rule.forEach((item) => {
|
||||
fields.push(JSON.stringify(item))
|
||||
rule.forEach((item: any) => {
|
||||
fields.push(formCreate.toJson(item))
|
||||
})
|
||||
return fields
|
||||
}
|
||||
|
||||
// 解码表单 Fields
|
||||
/** 解码表单 Fields */
|
||||
export const decodeFields = (fields: string[]) => {
|
||||
const rule: object[] = []
|
||||
fields.forEach((item) => {
|
||||
rule.push(JSON.parse(item))
|
||||
rule.push(formCreate.parseJson(item))
|
||||
})
|
||||
return rule
|
||||
}
|
||||
|
||||
// 设置表单的 Conf 和 Fields,适用 FcDesigner 场景
|
||||
export const setConfAndFields = (designerRef: object, conf: string, fields: string) => {
|
||||
/** 设置表单的 Conf 和 Fields,适用 FcDesigner 场景 */
|
||||
export const setConfAndFields = (designerRef: object, conf: string, fields: string[]) => {
|
||||
// @ts-ignore
|
||||
designerRef.value.setOption(JSON.parse(conf))
|
||||
designerRef.value.setOption(decodeConf(conf))
|
||||
// @ts-ignore
|
||||
designerRef.value.setRule(decodeFields(fields))
|
||||
}
|
||||
|
||||
// 设置表单的 Conf 和 Fields,适用 form-create 场景
|
||||
/** 设置表单的 Conf 和 Fields,适用 form-create 场景 */
|
||||
export const setConfAndFields2 = (
|
||||
detailPreview: object,
|
||||
conf: string,
|
||||
@@ -49,154 +56,10 @@ export const setConfAndFields2 = (
|
||||
detailPreview = detailPreview.value
|
||||
}
|
||||
|
||||
// 修复所有函数类型(解决设计器保存后函数变成字符串的问题)。例如说:
|
||||
// https://t.zsxq.com/rADff
|
||||
// https://t.zsxq.com/ZfbGt
|
||||
// https://t.zsxq.com/mHOoj
|
||||
// https://t.zsxq.com/BSylB
|
||||
const option = JSON.parse(conf)
|
||||
const rule = decodeFields(fields)
|
||||
// 🔧 修复所有函数类型 - 解决设计器保存后函数变成字符串的问题
|
||||
const fixFunctions = (obj: any) => {
|
||||
if (obj && typeof obj === 'object') {
|
||||
Object.keys(obj).forEach((key) => {
|
||||
// 检查是否是函数相关的属性
|
||||
if (isFunctionProperty(key)) {
|
||||
// 如果不是函数类型,重新构建为函数
|
||||
if (typeof obj[key] !== 'function') {
|
||||
obj[key] = createDefaultFunction(key)
|
||||
}
|
||||
} else if (typeof obj[key] === 'object' && obj[key] !== null) {
|
||||
// 递归处理嵌套对象
|
||||
fixFunctions(obj[key])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// 判断是否是函数属性
|
||||
const isFunctionProperty = (key: string): boolean => {
|
||||
const functionKeys = [
|
||||
'beforeFetch', // 请求前处理
|
||||
'afterFetch', // 请求后处理
|
||||
'onSubmit', // 表单提交
|
||||
'onReset', // 表单重置
|
||||
'onChange', // 值变化
|
||||
'onInput', // 输入事件
|
||||
'onClick', // 点击事件
|
||||
'onFocus', // 获取焦点
|
||||
'onBlur', // 失去焦点
|
||||
'onMounted', // 组件挂载
|
||||
'onCreated', // 组件创建
|
||||
'onReload', // 重新加载
|
||||
'remoteMethod', // 远程搜索方法
|
||||
'parseFunc', // 解析函数
|
||||
'validator', // 验证器
|
||||
'asyncValidator', // 异步验证器
|
||||
'formatter', // 格式化函数
|
||||
'parser', // 解析函数
|
||||
'beforeUpload', // 上传前处理
|
||||
'onSuccess', // 成功回调
|
||||
'onError', // 错误回调
|
||||
'onProgress', // 进度回调
|
||||
'onPreview', // 预览回调
|
||||
'onRemove', // 移除回调
|
||||
'onExceed', // 超出限制回调
|
||||
'filterMethod', // 过滤方法
|
||||
'sortMethod', // 排序方法
|
||||
'loadData', // 加载数据
|
||||
'renderContent', // 渲染内容
|
||||
'render' // 渲染函数
|
||||
]
|
||||
// 检查是否以函数相关前缀开头
|
||||
const functionPrefixes = ['on', 'before', 'after', 'handle']
|
||||
return functionKeys.includes(key) || functionPrefixes.some((prefix) => key.startsWith(prefix))
|
||||
}
|
||||
// 根据函数名创建默认函数
|
||||
const createDefaultFunction = (key: string): Function => {
|
||||
switch (key) {
|
||||
case 'beforeFetch':
|
||||
return (config: any) => {
|
||||
// 添加 Token 认证头。例如说:
|
||||
// https://t.zsxq.com/hK3FO
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
Authorization: 'Bearer ' + token
|
||||
}
|
||||
}
|
||||
// 添加通用请求头
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
// 添加时间戳防止缓存
|
||||
config.params = {
|
||||
...config.params,
|
||||
_t: Date.now()
|
||||
}
|
||||
return config
|
||||
}
|
||||
case 'afterFetch':
|
||||
return (data: any) => {
|
||||
return data
|
||||
}
|
||||
case 'onSubmit':
|
||||
return (_formData: any) => {
|
||||
return true
|
||||
}
|
||||
case 'onReset':
|
||||
return () => {
|
||||
return true
|
||||
}
|
||||
case 'onChange':
|
||||
return (_value: any, _oldValue: any) => {}
|
||||
case 'remoteMethod':
|
||||
return (query: string) => {
|
||||
console.log('remoteMethod被调用:', query)
|
||||
}
|
||||
case 'parseFunc':
|
||||
return (data: any) => {
|
||||
// 默认解析逻辑:如果是数组直接返回,否则尝试获取list属性
|
||||
if (Array.isArray(data)) {
|
||||
return data
|
||||
}
|
||||
return data?.list || data?.data || []
|
||||
}
|
||||
case 'validator':
|
||||
return (_rule: any, _value: any, callback: Function) => {
|
||||
callback()
|
||||
}
|
||||
case 'beforeUpload':
|
||||
return (_file: any) => {
|
||||
return true
|
||||
}
|
||||
default:
|
||||
// 通用默认函数
|
||||
return (...args: any[]) => {
|
||||
// 对于事件处理函数,返回true表示继续执行
|
||||
if (key.startsWith('on') || key.startsWith('handle')) {
|
||||
return true
|
||||
}
|
||||
// 对于其他函数,返回第一个参数(通常是数据传递)
|
||||
return args[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
// 修复 option 中的所有函数
|
||||
fixFunctions(option)
|
||||
// 修复 rule 中的所有函数(包括组件的 props)
|
||||
if (Array.isArray(rule)) {
|
||||
rule.forEach((item: any) => {
|
||||
fixFunctions(item)
|
||||
})
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
detailPreview.option = option
|
||||
detailPreview.option = decodeConf(conf)
|
||||
// @ts-ignore
|
||||
detailPreview.rule = rule
|
||||
detailPreview.rule = decodeFields(fields)
|
||||
|
||||
if (value) {
|
||||
// @ts-ignore
|
||||
|
||||
@@ -529,7 +529,6 @@ export function jsonParse(str: string) {
|
||||
* @param start 开始位置
|
||||
* @param end 结束位置
|
||||
*/
|
||||
|
||||
export const subString = (str: string, start: number, end: number) => {
|
||||
if (str.length > end) {
|
||||
return str.slice(start, end)
|
||||
|
||||
@@ -185,7 +185,7 @@ const { push } = useRouter()
|
||||
const permissionStore = usePermissionStore()
|
||||
const loginLoading = ref(false)
|
||||
const verify = ref()
|
||||
const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字
|
||||
const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字 pictureWord 文字验证码
|
||||
|
||||
const getShow = computed(() => unref(getLoginState) === LoginStateEnum.LOGIN)
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ const iconCircleCheck = useIcon({ icon: 'ep:circle-check' })
|
||||
const { validForm } = useFormValid(formSmsResetPassword)
|
||||
const { handleBackLogin, getLoginState, setLoginState } = useLoginState()
|
||||
const getShow = computed(() => unref(getLoginState) === LoginStateEnum.RESET_PASSWORD)
|
||||
const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字
|
||||
const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字 pictureWord 文字验证码
|
||||
|
||||
const validatePass2 = (_rule, value, callback) => {
|
||||
if (value === '') {
|
||||
|
||||
@@ -47,10 +47,7 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col
|
||||
:span="24"
|
||||
class="px-10px mt-[-20px] mb-[-20px]"
|
||||
>
|
||||
<el-col :span="24" class="px-10px mt-[-20px] mb-[-20px]">
|
||||
<el-form-item>
|
||||
<el-row justify="space-between" style="width: 100%">
|
||||
<el-col :span="6">
|
||||
@@ -177,7 +174,7 @@ const permissionStore = usePermissionStore()
|
||||
const redirect = ref<string>('')
|
||||
const loginLoading = ref(false)
|
||||
const verify = ref()
|
||||
const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字
|
||||
const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字 pictureWord 文字验证码
|
||||
|
||||
const getShow = computed(() => unref(getLoginState) === LoginStateEnum.LOGIN)
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" class="px-10px">
|
||||
<el-form-item prop="username">
|
||||
<el-form-item prop="nickname">
|
||||
<el-input
|
||||
v-model="registerData.registerForm.nickname"
|
||||
placeholder="昵称"
|
||||
@@ -104,7 +104,7 @@ import { useIcon } from '@/hooks/web/useIcon'
|
||||
import * as authUtil from '@/utils/auth'
|
||||
import { usePermissionStore } from '@/store/modules/permission'
|
||||
import * as LoginApi from '@/api/login'
|
||||
import { LoginStateEnum, useLoginState } from './useLogin'
|
||||
import { LoginStateEnum, useLoginState, useFormValid } from './useLogin'
|
||||
|
||||
defineOptions({ name: 'RegisterForm' })
|
||||
|
||||
@@ -113,13 +113,14 @@ const iconHouse = useIcon({ icon: 'ep:house' })
|
||||
const iconAvatar = useIcon({ icon: 'ep:avatar' })
|
||||
const iconLock = useIcon({ icon: 'ep:lock' })
|
||||
const formLogin = ref()
|
||||
const {validForm} = useFormValid(formLogin)
|
||||
const { handleBackLogin, getLoginState } = useLoginState()
|
||||
const { currentRoute, push } = useRouter()
|
||||
const permissionStore = usePermissionStore()
|
||||
const redirect = ref<string>('')
|
||||
const loginLoading = ref(false)
|
||||
const verify = ref()
|
||||
const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字
|
||||
const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字 pictureWord 文字验证码
|
||||
|
||||
const getShow = computed(() => unref(getLoginState) === LoginStateEnum.REGISTER)
|
||||
|
||||
@@ -170,6 +171,7 @@ const registerData = reactive({
|
||||
}
|
||||
})
|
||||
|
||||
const loading = ref() // ElLoading.service 返回的实例
|
||||
// 提交注册
|
||||
const handleRegister = async (params: any) => {
|
||||
loading.value = true
|
||||
@@ -183,6 +185,11 @@ const handleRegister = async (params: any) => {
|
||||
registerData.registerForm.captchaVerification = params.captchaVerification
|
||||
}
|
||||
|
||||
const data = await validForm()
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
|
||||
const res = await LoginApi.register(registerData.registerForm)
|
||||
if (!res) {
|
||||
return
|
||||
@@ -242,7 +249,6 @@ const getTenantByWebsite = async () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
const loading = ref() // ElLoading.service 返回的实例
|
||||
|
||||
watch(
|
||||
() => currentRoute.value,
|
||||
|
||||
@@ -48,7 +48,7 @@ const rules = reactive<FormRules>({
|
||||
mobile: [
|
||||
{ required: true, message: t('profile.rules.phone'), trigger: 'blur' },
|
||||
{
|
||||
pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/,
|
||||
pattern: /^1[3-9]\d{9}$/,
|
||||
message: t('profile.rules.truephone'),
|
||||
trigger: 'blur'
|
||||
}
|
||||
@@ -78,6 +78,21 @@ const schema = reactive<FormSchema[]>([
|
||||
}
|
||||
])
|
||||
const formRef = ref<FormExpose>() // 表单 Ref
|
||||
|
||||
// 监听 userStore 中头像的变化,同步更新表单数据
|
||||
watch(
|
||||
() => userStore.getUser.avatar,
|
||||
(newAvatar) => {
|
||||
if (newAvatar && formRef.value) {
|
||||
// 直接更新表单模型中的头像字段
|
||||
const formModel = formRef.value.formModel
|
||||
if (formModel) {
|
||||
formModel.avatar = newAvatar
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const submit = () => {
|
||||
const elForm = unref(formRef)?.getElFormRef()
|
||||
if (!elForm) return
|
||||
@@ -87,17 +102,19 @@ const submit = () => {
|
||||
await updateUserProfile(data)
|
||||
message.success(t('common.updateSuccess'))
|
||||
const profile = await init()
|
||||
userStore.setUserNicknameAction(profile.nickname)
|
||||
await userStore.setUserNicknameAction(profile.nickname)
|
||||
// 发送成功事件
|
||||
emit('success')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
const res = await getUserProfile()
|
||||
unref(formRef)?.setValues(res)
|
||||
return res
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await init()
|
||||
})
|
||||
|
||||
@@ -49,18 +49,31 @@
|
||||
<script lang="ts" setup>
|
||||
import { formatDate } from '@/utils/formatTime'
|
||||
import UserAvatar from './UserAvatar.vue'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
import { getUserProfile, ProfileVO } from '@/api/system/user/profile'
|
||||
|
||||
defineOptions({ name: 'ProfileUser' })
|
||||
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const userInfo = ref({} as ProfileVO)
|
||||
|
||||
const getUserInfo = async () => {
|
||||
const users = await getUserProfile()
|
||||
userInfo.value = users
|
||||
}
|
||||
|
||||
// 监听 userStore 中头像的变化,同步更新本地 userInfo
|
||||
watch(
|
||||
() => userStore.getUser.avatar,
|
||||
(newAvatar) => {
|
||||
if (newAvatar && userInfo.value) {
|
||||
userInfo.value.avatar = newAvatar
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 暴露刷新方法
|
||||
defineExpose({
|
||||
refresh: getUserInfo
|
||||
|
||||
@@ -44,11 +44,11 @@ const equalToPassword = (_rule, value, callback) => {
|
||||
const rules = reactive<FormRules>({
|
||||
oldPassword: [
|
||||
{ required: true, message: t('profile.password.oldPwdMsg'), trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: t('profile.password.pwdRules'), trigger: 'blur' }
|
||||
{ min: 4, max: 16, message: t('profile.password.pwdRules'), trigger: 'blur' }
|
||||
],
|
||||
newPassword: [
|
||||
{ required: true, message: t('profile.password.newPwdMsg'), trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: t('profile.password.pwdRules'), trigger: 'blur' }
|
||||
{ min: 4, max: 16, message: t('profile.password.pwdRules'), trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: t('profile.password.cfPwdMsg'), trigger: 'blur' },
|
||||
|
||||
394
src/views/ai/chat/index/components/message/MessageFileUpload.vue
Normal file
394
src/views/ai/chat/index/components/message/MessageFileUpload.vue
Normal file
@@ -0,0 +1,394 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative inline-block"
|
||||
@mouseenter="showTooltipHandler"
|
||||
@mouseleave="hideTooltipHandler"
|
||||
>
|
||||
<!-- 文件上传按钮 -->
|
||||
<el-button
|
||||
v-if="!disabled"
|
||||
circle
|
||||
size="small"
|
||||
class="upload-btn relative transition-all-200ms"
|
||||
:class="{ 'has-files': fileList.length > 0 }"
|
||||
@click="triggerFileInput"
|
||||
:disabled="fileList.length >= limit"
|
||||
>
|
||||
<Icon icon="ep:paperclip" :size="16" />
|
||||
<!-- 文件数量徽章 -->
|
||||
<span
|
||||
v-if="fileList.length > 0"
|
||||
class="absolute -top-1 -right-1 bg-red-500 text-white text-10px px-1 rounded-8px min-w-4 h-4 flex items-center justify-center leading-none font-medium"
|
||||
>
|
||||
{{ fileList.length }}
|
||||
</span>
|
||||
</el-button>
|
||||
|
||||
<!-- 隐藏的文件输入框 -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
multiple
|
||||
style="display: none"
|
||||
:accept="acceptTypes"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<!-- Hover 显示的文件列表 -->
|
||||
<div
|
||||
v-if="fileList.length > 0 && showTooltip"
|
||||
class="file-tooltip"
|
||||
@mouseenter="showTooltipHandler"
|
||||
@mouseleave="hideTooltipHandler"
|
||||
>
|
||||
<div class="tooltip-arrow"></div>
|
||||
<div class="max-h-200px overflow-y-auto file-list">
|
||||
<div
|
||||
v-for="(file, index) in fileList"
|
||||
:key="index"
|
||||
class="flex items-center justify-between p-2 mb-1 bg-gray-50 rounded-6px text-12px transition-all-200ms last:mb-0 hover:bg-gray-100"
|
||||
:class="{ 'opacity-70': file.uploading }"
|
||||
>
|
||||
<div class="flex items-center flex-1 min-w-0">
|
||||
<Icon :icon="getFileIcon(file.name)" class="text-blue-500 mr-2 flex-shrink-0" />
|
||||
<span
|
||||
class="font-medium text-gray-900 mr-1 overflow-hidden text-ellipsis whitespace-nowrap flex-1"
|
||||
>{{ file.name }}</span
|
||||
>
|
||||
<span class="text-gray-500 flex-shrink-0 text-11px"
|
||||
>({{ formatFileSize(file.size) }})</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-shrink-0 ml-2">
|
||||
<el-progress
|
||||
v-if="file.uploading"
|
||||
:percentage="file.progress || 0"
|
||||
:show-text="false"
|
||||
size="small"
|
||||
class="w-60px"
|
||||
/>
|
||||
<el-button
|
||||
v-else-if="!disabled"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="removeFile(index)"
|
||||
>
|
||||
<Icon icon="ep:close" :size="12" />
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUpload } from '@/components/UploadFile/src/useUpload'
|
||||
import { formatFileSize, getFileIcon } from '@/utils/file'
|
||||
|
||||
export interface FileItem {
|
||||
name: string
|
||||
size: number
|
||||
url?: string
|
||||
uploading?: boolean
|
||||
progress?: number
|
||||
raw?: File
|
||||
}
|
||||
|
||||
defineOptions({ name: 'MessageFileUpload' })
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => []
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 5
|
||||
},
|
||||
maxSize: {
|
||||
type: Number,
|
||||
default: 10 // MB
|
||||
},
|
||||
acceptTypes: {
|
||||
type: String,
|
||||
default: '.jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.txt,.xls,.xlsx,.ppt,.pptx,.csv,.md'
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'upload-success', 'upload-error'])
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement>()
|
||||
const fileList = ref<FileItem[]>([]) // 内部管理文件列表
|
||||
const uploadedUrls = ref<string[]>([]) // 已上传的 URL 列表
|
||||
const showTooltip = ref(false) // 控制 tooltip 显示
|
||||
const hideTimer = ref<NodeJS.Timeout | null>(null) // 隐藏延迟定时器
|
||||
const message = useMessage()
|
||||
const { httpRequest } = useUpload()
|
||||
|
||||
/** 监听 v-model 变化 */
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
uploadedUrls.value = [...newVal]
|
||||
// 如果外部清空了 URLs,也清空内部文件列表
|
||||
if (newVal.length === 0) {
|
||||
fileList.value = []
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
/** 触发文件选择 */
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
/** 显示 tooltip */
|
||||
const showTooltipHandler = () => {
|
||||
if (hideTimer.value) {
|
||||
clearTimeout(hideTimer.value)
|
||||
hideTimer.value = null
|
||||
}
|
||||
showTooltip.value = true
|
||||
}
|
||||
|
||||
/** 隐藏 tooltip */
|
||||
const hideTooltipHandler = () => {
|
||||
hideTimer.value = setTimeout(() => {
|
||||
showTooltip.value = false
|
||||
hideTimer.value = null
|
||||
}, 300) // 300ms 延迟隐藏
|
||||
}
|
||||
|
||||
/** 处理文件选择 */
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const files = Array.from(target.files || [])
|
||||
if (files.length === 0) {
|
||||
return
|
||||
}
|
||||
// 检查总文件数是否超过限制
|
||||
if (files.length + fileList.value.length > props.limit) {
|
||||
message.error(`最多只能上传 ${props.limit} 个文件`)
|
||||
target.value = '' // 清空输入
|
||||
return
|
||||
}
|
||||
// 处理每个文件
|
||||
files.forEach((file) => {
|
||||
if (file.size > props.maxSize * 1024 * 1024) {
|
||||
message.error(`文件 ${file.name} 大小超过 ${props.maxSize}MB`)
|
||||
return
|
||||
}
|
||||
const fileItem: FileItem = {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
uploading: true,
|
||||
progress: 0,
|
||||
raw: file
|
||||
}
|
||||
fileList.value.push(fileItem)
|
||||
// 立即开始上传
|
||||
uploadFile(fileItem)
|
||||
})
|
||||
|
||||
// 清空 input 值,允许重复选择相同文件
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
/** 上传文件 */
|
||||
const uploadFile = async (fileItem: FileItem) => {
|
||||
try {
|
||||
// 模拟上传进度
|
||||
const progressInterval = setInterval(() => {
|
||||
if (fileItem.progress! < 90) {
|
||||
fileItem.progress = (fileItem.progress || 0) + Math.random() * 10
|
||||
}
|
||||
}, 100)
|
||||
|
||||
// 调用上传接口
|
||||
// const formData = new FormData()
|
||||
// formData.append('file', fileItem.raw!)
|
||||
const response = await httpRequest({
|
||||
file: fileItem.raw!,
|
||||
filename: fileItem.name
|
||||
} as any)
|
||||
fileItem.uploading = false
|
||||
fileItem.progress = 100
|
||||
fileItem.url = (response as any).data
|
||||
// 添加到 URL 列表
|
||||
uploadedUrls.value.push(fileItem.url!)
|
||||
|
||||
clearInterval(progressInterval)
|
||||
|
||||
emit('upload-success', fileItem)
|
||||
updateModelValue()
|
||||
} catch (error) {
|
||||
fileItem.uploading = false
|
||||
message.error(`文件 ${fileItem.name} 上传失败`)
|
||||
emit('upload-error', error)
|
||||
|
||||
// 移除上传失败的文件
|
||||
const index = fileList.value.indexOf(fileItem)
|
||||
if (index > -1) {
|
||||
removeFile(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除文件 */
|
||||
const removeFile = (index: number) => {
|
||||
// 从 URL 列表中移除
|
||||
const removedFile = fileList.value[index]
|
||||
fileList.value.splice(index, 1)
|
||||
if (removedFile.url) {
|
||||
const urlIndex = uploadedUrls.value.indexOf(removedFile.url)
|
||||
if (urlIndex > -1) {
|
||||
uploadedUrls.value.splice(urlIndex, 1)
|
||||
}
|
||||
}
|
||||
|
||||
updateModelValue()
|
||||
}
|
||||
|
||||
/** 更新 v-model */
|
||||
const updateModelValue = () => {
|
||||
emit('update:modelValue', [...uploadedUrls.value])
|
||||
}
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
triggerFileInput,
|
||||
clearFiles: () => {
|
||||
fileList.value = []
|
||||
uploadedUrls.value = []
|
||||
updateModelValue()
|
||||
}
|
||||
})
|
||||
|
||||
// 组件销毁时清理定时器
|
||||
onUnmounted(() => {
|
||||
if (hideTimer.value) {
|
||||
clearTimeout(hideTimer.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 上传按钮样式 */
|
||||
.upload-btn {
|
||||
--el-button-bg-color: transparent;
|
||||
--el-button-border-color: transparent;
|
||||
--el-button-hover-bg-color: var(--el-fill-color-light);
|
||||
--el-button-hover-border-color: transparent;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.upload-btn.has-files {
|
||||
color: var(--el-color-primary);
|
||||
--el-button-hover-bg-color: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.file-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: white;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
padding: 8px;
|
||||
animation: fadeInDown 0.2s ease;
|
||||
}
|
||||
|
||||
.tooltip-arrow {
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 5px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
/* Tooltip 箭头伪元素 */
|
||||
.tooltip-arrow::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 1px;
|
||||
left: -4px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 4px solid white;
|
||||
}
|
||||
|
||||
@keyframes fadeInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.file-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.file-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.file-list::-webkit-scrollbar-thumb {
|
||||
background: var(--el-border-color-light);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.file-list::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--el-border-color);
|
||||
}
|
||||
/* 滚动条样式 */
|
||||
.file-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.file-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.file-list::-webkit-scrollbar-thumb {
|
||||
background: var(--el-border-color-light);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.file-list::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--el-border-color);
|
||||
}
|
||||
</style>
|
||||
66
src/views/ai/chat/index/components/message/MessageFiles.vue
Normal file
66
src/views/ai/chat/index/components/message/MessageFiles.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div v-if="attachmentUrls && attachmentUrls.length > 0" class="mt-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="(url, index) in attachmentUrls"
|
||||
:key="index"
|
||||
class="flex items-center p-3 bg-gray-1 rounded-2 cursor-pointer transition-all duration-200 min-w-40 max-w-70 border border-transparent hover:(bg-gray-2 -translate-y-1 shadow-lg)"
|
||||
@click="handleFileClick(url)"
|
||||
>
|
||||
<div class="mr-3 flex-shrink-0">
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-8 rounded-1.5 text-white font-bold"
|
||||
:class="getFileTypeClass(getFileNameFromUrl(url))"
|
||||
>
|
||||
<Icon :icon="getFileIcon(getFileNameFromUrl(url))" :size="20" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="text-sm font-medium text-gray-8 leading-tight mb-1 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:title="getFileNameFromUrl(url)"
|
||||
>
|
||||
{{ getFileNameFromUrl(url) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getFileIcon, getFileNameFromUrl, isImage } from '@/utils/file'
|
||||
|
||||
defineOptions({ name: 'MessageFiles' })
|
||||
|
||||
defineProps<{
|
||||
attachmentUrls?: string[]
|
||||
}>()
|
||||
|
||||
/** 获取文件类型样式类 */
|
||||
const getFileTypeClass = (filename: string): string => {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
if (isImage(ext)) {
|
||||
return 'bg-gradient-to-br from-yellow-4 to-orange-5'
|
||||
} else if (['pdf'].includes(ext)) {
|
||||
return 'bg-gradient-to-br from-red-5 to-red-7'
|
||||
} else if (['doc', 'docx'].includes(ext)) {
|
||||
return 'bg-gradient-to-br from-blue-6 to-blue-8'
|
||||
} else if (['xls', 'xlsx'].includes(ext)) {
|
||||
return 'bg-gradient-to-br from-green-6 to-green-8'
|
||||
} else if (['ppt', 'pptx'].includes(ext)) {
|
||||
return 'bg-gradient-to-br from-orange-6 to-orange-8'
|
||||
} else if (['mp3', 'wav', 'm4a', 'aac'].includes(ext)) {
|
||||
return 'bg-gradient-to-br from-purple-5 to-purple-7'
|
||||
} else if (['mp4', 'avi', 'mov', 'wmv'].includes(ext)) {
|
||||
return 'bg-gradient-to-br from-red-5 to-red-7'
|
||||
} else {
|
||||
return 'bg-gradient-to-br from-gray-5 to-gray-7'
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击文件 */
|
||||
const handleFileClick = (url: string) => {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
</script>
|
||||
@@ -14,11 +14,17 @@
|
||||
class="relative flex flex-col break-words bg-[var(--el-fill-color-light)] shadow-[0_0_0_1px_var(--el-border-color-light)] rounded-10px pt-10px px-10px pb-5px"
|
||||
ref="markdownViewRef"
|
||||
>
|
||||
<MessageReasoning
|
||||
:reasoning-content="item.reasoningContent || ''"
|
||||
:content="item.content || ''"
|
||||
/>
|
||||
<MarkdownView
|
||||
class="text-[var(--el-text-color-primary)] text-[0.95rem]"
|
||||
:content="item.content"
|
||||
/>
|
||||
<MessageFiles :attachment-urls="item.attachmentUrls" />
|
||||
<MessageKnowledge v-if="item.segments" :segments="item.segments" />
|
||||
<MessageWebSearch v-if="item.webSearchPages" :web-search-pages="item.webSearchPages" />
|
||||
</div>
|
||||
<div class="flex flex-row mt-8px">
|
||||
<el-button
|
||||
@@ -48,11 +54,21 @@
|
||||
<div>
|
||||
<el-text class="text-left leading-30px">{{ formatDate(item.createTime) }}</el-text>
|
||||
</div>
|
||||
<!-- 附件显示行 -->
|
||||
<div
|
||||
v-if="item.attachmentUrls && item.attachmentUrls.length > 0"
|
||||
class="flex flex-row-reverse mb-8px"
|
||||
>
|
||||
<MessageFiles :attachment-urls="item.attachmentUrls" />
|
||||
</div>
|
||||
<!-- 文本内容行 -->
|
||||
<div class="flex flex-row-reverse">
|
||||
<div
|
||||
v-if="item.content && item.content.trim()"
|
||||
class="text-[0.95rem] text-[var(--el-color-white)] inline bg-[var(--el-color-primary)] shadow-[0_0_0_1px_var(--el-color-primary)] rounded-10px p-10px w-auto break-words whitespace-pre-wrap"
|
||||
>{{ item.content }}</div
|
||||
>
|
||||
{{ item.content }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row-reverse mt-8px">
|
||||
<el-button
|
||||
@@ -98,6 +114,9 @@ import { PropType } from 'vue'
|
||||
import { formatDate } from '@/utils/formatTime'
|
||||
import MarkdownView from '@/components/MarkdownView/index.vue'
|
||||
import MessageKnowledge from './MessageKnowledge.vue'
|
||||
import MessageReasoning from './MessageReasoning.vue'
|
||||
import MessageFiles from './MessageFiles.vue'
|
||||
import MessageWebSearch from './MessageWebSearch.vue'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { ArrowDownBold, Edit, RefreshRight } from '@element-plus/icons-vue'
|
||||
import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
|
||||
@@ -107,7 +126,7 @@ import userAvatarDefaultImg from '@/assets/imgs/avatar.gif'
|
||||
import roleAvatarDefaultImg from '@/assets/ai/gpt.svg'
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { copy } = useClipboard() // 初始化 copy 到粘贴板
|
||||
const { copy } = useClipboard({ legacy: true }) // 初始化 copy 到粘贴板
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 判断“消息列表”滚动的位置(用于判断是否需要滚动到消息最下方)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div v-if="shouldShowComponent" class="mt-10px">
|
||||
<!-- 推理过程标题栏 -->
|
||||
<div
|
||||
class="flex items-center justify-between cursor-pointer p-8px rounded-t-8px bg-gradient-to-r from-blue-50 to-purple-50 border border-b-0 border-gray-200/60 hover:from-blue-100 hover:to-purple-100 transition-all duration-200"
|
||||
@click="toggleExpanded"
|
||||
>
|
||||
<div class="flex items-center gap-6px text-14px font-medium text-gray-700">
|
||||
<el-icon :size="16" class="text-blue-600">
|
||||
<ChatDotSquare />
|
||||
</el-icon>
|
||||
<span>{{ titleText }}</span>
|
||||
</div>
|
||||
<el-icon
|
||||
:size="14"
|
||||
class="text-gray-500 transition-transform duration-200"
|
||||
:class="{ 'transform rotate-180': isExpanded }"
|
||||
>
|
||||
<ArrowDown />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 推理内容区域 -->
|
||||
<div
|
||||
v-show="isExpanded"
|
||||
class="max-h-300px overflow-y-auto p-12px bg-white/70 backdrop-blur-sm border border-t-0 border-gray-200/60 rounded-b-8px shadow-sm"
|
||||
>
|
||||
<MarkdownView
|
||||
v-if="props.reasoningContent"
|
||||
class="text-gray-700 text-13px leading-relaxed"
|
||||
:content="props.reasoningContent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ArrowDown, ChatDotSquare } from '@element-plus/icons-vue'
|
||||
import MarkdownView from '@/components/MarkdownView/index.vue'
|
||||
|
||||
// 定义 props
|
||||
const props = defineProps<{
|
||||
reasoningContent?: string
|
||||
content?: string
|
||||
}>()
|
||||
|
||||
const isExpanded = ref(true) // 默认展开
|
||||
|
||||
/** 计算属性:判断是否应该显示组件(有思考内容时,则展示) */
|
||||
const shouldShowComponent = computed(() => {
|
||||
return !(!props.reasoningContent || props.reasoningContent.trim() === '')
|
||||
})
|
||||
|
||||
/** 计算属性:标题文本 */
|
||||
const titleText = computed(() => {
|
||||
const hasReasoningContent = props.reasoningContent && props.reasoningContent.trim() !== ''
|
||||
const hasContent = props.content && props.content.trim() !== ''
|
||||
if (hasReasoningContent && !hasContent) {
|
||||
return '深度思考中'
|
||||
}
|
||||
return '已深度思考'
|
||||
})
|
||||
|
||||
/** 切换展开/收缩状态 */
|
||||
const toggleExpanded = () => {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义滚动条样式 */
|
||||
.max-h-300px::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.max-h-300px::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.max-h-300px::-webkit-scrollbar-thumb {
|
||||
background: rgba(156, 163, 175, 0.4);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.max-h-300px::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(156, 163, 175, 0.6);
|
||||
}
|
||||
</style>
|
||||
190
src/views/ai/chat/index/components/message/MessageWebSearch.vue
Normal file
190
src/views/ai/chat/index/components/message/MessageWebSearch.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<!-- 联网搜索结果组件 -->
|
||||
<template>
|
||||
<!-- 联网搜索结果列表 -->
|
||||
<div
|
||||
v-if="webSearchPages && webSearchPages.length > 0"
|
||||
class="mt-10px p-10px rounded-8px bg-[#f5f5f5]"
|
||||
>
|
||||
<!-- 标题栏:可点击展开/收起 -->
|
||||
<div
|
||||
class="text-14px text-[#666] mb-8px flex items-center justify-between cursor-pointer hover:text-[#409eff]"
|
||||
@click="toggleExpanded"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Icon icon="ep:search" class="mr-5px" />
|
||||
联网搜索结果 ({{ webSearchPages.length }} 条)
|
||||
</div>
|
||||
<Icon
|
||||
:icon="isExpanded ? 'ep:arrow-up' : 'ep:arrow-down'"
|
||||
class="text-12px transition-transform duration-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 可展开的搜索结果列表 -->
|
||||
<div v-show="isExpanded" class="flex flex-col gap-8px transition-all duration-200 ease-in-out">
|
||||
<div
|
||||
v-for="(result, index) in webSearchPages"
|
||||
:key="index"
|
||||
class="p-10px bg-white rounded-6px cursor-pointer transition-all hover:bg-[#e6f4ff]"
|
||||
@click="handleClick(result)"
|
||||
>
|
||||
<div class="flex items-start gap-8px">
|
||||
<!-- 网站图标 -->
|
||||
<div class="flex-shrink-0 w-16px h-16px mt-2px">
|
||||
<img
|
||||
v-if="result.icon"
|
||||
:src="result.icon"
|
||||
:alt="result.name"
|
||||
class="w-full h-full object-contain rounded-2px"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<Icon v-else icon="ep:link" class="w-full h-full text-[#666]" />
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- 标题和来源 -->
|
||||
<div class="flex items-center gap-4px mb-4px">
|
||||
<span class="text-12px text-[#999] truncate">{{ result.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 主标题 -->
|
||||
<div class="text-14px text-[#1a73e8] font-medium mb-4px line-clamp-2 leading-[1.4]">
|
||||
{{ result.title }}
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<div class="text-13px text-[#666] line-clamp-2 leading-[1.4] mb-4px">
|
||||
{{ result.snippet }}
|
||||
</div>
|
||||
|
||||
<!-- URL -->
|
||||
<div class="text-12px text-[#006621] truncate">
|
||||
{{ result.url }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 联网搜索详情弹窗 -->
|
||||
<el-popover
|
||||
v-model:visible="dialogVisible"
|
||||
:width="600"
|
||||
trigger="click"
|
||||
placement="top-start"
|
||||
:offset="55"
|
||||
popper-class="web-search-popover"
|
||||
>
|
||||
<template #reference>
|
||||
<div ref="resultRef"></div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-if="selectedResult">
|
||||
<!-- 标题区域 -->
|
||||
<div class="flex items-start gap-8px mb-12px">
|
||||
<div class="flex-shrink-0 w-20px h-20px mt-2px">
|
||||
<img
|
||||
v-if="selectedResult.icon"
|
||||
:src="selectedResult.icon"
|
||||
:alt="selectedResult.name"
|
||||
class="w-full h-full object-contain rounded-2px"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<Icon v-else icon="ep:link" class="w-full h-full text-[#666]" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-16px font-bold text-[#333] mb-4px line-clamp-2">
|
||||
{{ selectedResult.title }}
|
||||
</div>
|
||||
<div class="text-12px text-[#999] mb-4px">{{ selectedResult.name }}</div>
|
||||
<div class="text-12px text-[#006621] break-all">{{ selectedResult.url }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="max-h-[60vh] overflow-y-auto">
|
||||
<!-- 简短描述 -->
|
||||
<div class="mb-12px">
|
||||
<div class="text-14px font-medium text-[#333] mb-6px">简短描述</div>
|
||||
<div class="text-14px leading-[1.6] text-[#666] bg-[#f8f9fa] p-10px rounded-6px">
|
||||
{{ selectedResult.snippet }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容摘要 -->
|
||||
<div v-if="selectedResult.summary">
|
||||
<div class="text-14px font-medium text-[#333] mb-6px">内容摘要</div>
|
||||
<div
|
||||
class="text-14px leading-[1.6] text-[#333] bg-[#f8f9fa] p-10px rounded-6px whitespace-pre-wrap"
|
||||
>
|
||||
{{ selectedResult.summary }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex justify-end gap-8px mt-12px pt-12px border-t border-[#eee]">
|
||||
<el-button size="small" @click="dialogVisible = false">关闭</el-button>
|
||||
<el-button type="primary" size="small" @click="openUrl(selectedResult.url)">
|
||||
访问原文
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
webSearchPages: {
|
||||
name: string // 名称
|
||||
icon: string // 图标
|
||||
title: string // 标题
|
||||
url: string // URL
|
||||
snippet: string // 内容的简短描述
|
||||
summary: string // 内容的文本摘要
|
||||
}[]
|
||||
}>()
|
||||
|
||||
const isExpanded = ref(false) // 是否展开搜索结果
|
||||
const selectedResult = ref<{
|
||||
name: string
|
||||
icon: string
|
||||
title: string
|
||||
url: string
|
||||
snippet: string
|
||||
summary: string
|
||||
} | null>(null) // 选中的搜索结果
|
||||
const dialogVisible = ref(false) // 详情弹窗
|
||||
const resultRef = ref<HTMLElement>() // 详情弹窗 Ref
|
||||
|
||||
/** 切换展开/收起状态 */
|
||||
const toggleExpanded = () => {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
|
||||
/** 点击搜索结果处理 */
|
||||
const handleClick = (result: any) => {
|
||||
selectedResult.value = result
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
/** 处理图片加载错误 */
|
||||
const handleImageError = (event: Event) => {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.style.display = 'none'
|
||||
}
|
||||
|
||||
/** 打开URL */
|
||||
const openUrl = (url: string) => {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.web-search-popover {
|
||||
max-width: 600px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,21 +0,0 @@
|
||||
<!-- header -->
|
||||
<template>
|
||||
<el-header class="flex flex-row justify-between items-center px-10px whitespace-nowrap text-ellipsis w-full" :style="{ backgroundColor: 'var(--el-bg-color-page)' }">
|
||||
<div class="text-20px font-bold overflow-hidden max-w-220px" :style="{ color: 'var(--el-text-color-primary)' }">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="flex flex-row">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</el-header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 设置组件属性
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-row flex-wrap relative h-full overflow-auto px-25px pb-140px items-start content-start justify-start"
|
||||
class="flex flex-row flex-wrap relative h-full overflow-auto pb-140px items-start content-start justify-start"
|
||||
ref="tabsRef"
|
||||
@scroll="handleTabsScroll"
|
||||
>
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
<!-- chat 角色仓库 -->
|
||||
<template>
|
||||
<el-container
|
||||
class="role-container absolute w-full h-full m-0 p-0 left-0 right-0 top-0 bottom-0 bg-[var(--el-bg-color)] overflow-hidden flex !flex-col"
|
||||
>
|
||||
<el-container class="bg-[var(--el-bg-color)] -mt-25px">
|
||||
<ChatRoleForm ref="formRef" @success="handlerAddRoleSuccess" />
|
||||
<!-- header -->
|
||||
<RoleHeader title="角色仓库" class="relative" />
|
||||
<!-- main -->
|
||||
<el-main class="flex-1 overflow-hidden m-0 !p-0 relative">
|
||||
<div class="mx-5 mt-5 mb-0 absolute right-0 -top-1.25 z-100">
|
||||
<div class="mx-3 mt-3 mb-0 absolute right-0 -top-1.25 z-100">
|
||||
<!-- 搜索按钮 -->
|
||||
<el-input
|
||||
:loading="loading"
|
||||
@@ -30,16 +26,8 @@
|
||||
</el-button>
|
||||
</div>
|
||||
<!-- tabs -->
|
||||
<el-tabs
|
||||
v-model="activeTab"
|
||||
@tab-click="handleTabsClick"
|
||||
class="relative h-full [&_.el-tabs__nav-scroll]:my-2.5 [&_.el-tabs__nav-scroll]:mx-5"
|
||||
>
|
||||
<el-tab-pane
|
||||
label="我的角色"
|
||||
name="my-role"
|
||||
class="flex flex-col h-full overflow-y-auto relative"
|
||||
>
|
||||
<el-tabs v-model="activeTab" @tab-click="handleTabsClick" class="relative h-full">
|
||||
<el-tab-pane label="我的角色" name="my-role" class="flex flex-col h-full overflow-y-auto">
|
||||
<RoleList
|
||||
:loading="loading"
|
||||
:role-list="myRoleList"
|
||||
@@ -48,12 +36,12 @@
|
||||
@on-edit="handlerCardEdit"
|
||||
@on-use="handlerCardUse"
|
||||
@on-page="handlerCardPage('my')"
|
||||
class="mt-20px"
|
||||
class="mt-3"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="公共角色" name="public-role">
|
||||
<el-tab-pane label="公共角色" name="public-role" class="!pt-2">
|
||||
<RoleCategoryList
|
||||
class="mx-6.75"
|
||||
class="mx-3"
|
||||
:category-list="categoryList"
|
||||
:active="activeCategory"
|
||||
@on-category-click="handlerCategoryClick"
|
||||
@@ -64,7 +52,7 @@
|
||||
@on-edit="handlerCardEdit"
|
||||
@on-use="handlerCardUse"
|
||||
@on-page="handlerCardPage('public')"
|
||||
class="mt-20px"
|
||||
class="mt-3"
|
||||
loading
|
||||
/>
|
||||
</el-tab-pane>
|
||||
@@ -75,7 +63,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import RoleHeader from './RoleHeader.vue'
|
||||
import RoleList from './RoleList.vue'
|
||||
import ChatRoleForm from '@/views/ai/model/chatRole/ChatRoleForm.vue'
|
||||
import RoleCategoryList from './RoleCategoryList.vue'
|
||||
@@ -83,8 +70,11 @@ import { ChatRoleApi, ChatRolePageReqVO, ChatRoleVO } from '@/api/ai/model/chatR
|
||||
import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { TabsPaneContext } from 'element-plus'
|
||||
import { useTagsViewStore } from '@/store/modules/tagsView'
|
||||
|
||||
const router = useRouter() // 路由对象
|
||||
const { currentRoute } = useRouter() // 路由
|
||||
const { delView } = useTagsViewStore() // 视图操作
|
||||
|
||||
// 属性定义
|
||||
const loading = ref<boolean>(false) // 加载中
|
||||
@@ -134,7 +124,7 @@ const getPublicRole = async (append?: boolean) => {
|
||||
name: search.value,
|
||||
publicStatus: true
|
||||
}
|
||||
const { total, list } = await ChatRoleApi.getMyPage(params)
|
||||
const { list } = await ChatRoleApi.getMyPage(params)
|
||||
if (append) {
|
||||
publicRoleList.value.push.apply(publicRoleList.value, list)
|
||||
} else {
|
||||
@@ -214,7 +204,8 @@ const handlerCardUse = async (role) => {
|
||||
const conversationId = await ChatConversationApi.createChatConversationMy(data)
|
||||
|
||||
// 2. 跳转页面
|
||||
await router.push({
|
||||
delView(unref(currentRoute))
|
||||
await router.replace({
|
||||
name: 'AiChat',
|
||||
query: {
|
||||
conversationId: conversationId
|
||||
@@ -233,6 +224,23 @@ onMounted(async () => {
|
||||
<!-- 覆盖 element plus css -->
|
||||
<style lang="scss">
|
||||
.el-tabs__nav-scroll {
|
||||
margin: 10px 20px;
|
||||
margin: 2px 8px !important;
|
||||
}
|
||||
|
||||
.el-tabs__header {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.el-tabs__nav-wrap {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.el-tabs__content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.el-tab-pane {
|
||||
padding: 8px 0 0 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<el-container class="absolute flex-1 top-0 left-0 h-full w-full">
|
||||
<!-- 左侧:对话列表 -->
|
||||
<ConversationList
|
||||
:active-id="activeConversationId"
|
||||
:active-id="activeConversationId?.toString() || ''"
|
||||
ref="conversationListRef"
|
||||
@on-conversation-create="handleConversationCreateSuccess"
|
||||
@on-conversation-click="handleConversationClick"
|
||||
@@ -56,7 +56,7 @@
|
||||
/>
|
||||
<!-- 情况四:消息列表不为空 -->
|
||||
<MessageList
|
||||
v-if="!activeMessageListLoading && messageList.length > 0"
|
||||
v-if="!activeMessageListLoading && messageList.length > 0 && activeConversation"
|
||||
ref="messageRef"
|
||||
:conversation="activeConversation"
|
||||
:list="messageList"
|
||||
@@ -83,11 +83,15 @@
|
||||
@compositionstart="onCompositionstart"
|
||||
@compositionend="onCompositionend"
|
||||
placeholder="问我任何问题...(Shift+Enter 换行,按下 Enter 发送)"
|
||||
></textarea>
|
||||
>
|
||||
</textarea>
|
||||
<div class="flex justify-between pb-0 pt-5px">
|
||||
<div>
|
||||
<div class="flex items-center">
|
||||
<MessageFileUpload v-model="uploadFiles" :limit="5" :max-size="10" class="mr-10px" />
|
||||
<el-switch v-model="enableContext" />
|
||||
<span class="ml-5px text-14px text-#8f8f8f">上下文</span>
|
||||
<span class="ml-5px mr-15px text-14px text-#8f8f8f">上下文</span>
|
||||
<el-switch v-model="enableWebSearch" />
|
||||
<span class="ml-5px text-14px text-#8f8f8f">联网搜索</span>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
@@ -128,6 +132,7 @@ import MessageList from './components/message/MessageList.vue'
|
||||
import MessageListEmpty from './components/message/MessageListEmpty.vue'
|
||||
import MessageLoading from './components/message/MessageLoading.vue'
|
||||
import MessageNewConversation from './components/message/MessageNewConversation.vue'
|
||||
import MessageFileUpload from './components/message/MessageFileUpload.vue'
|
||||
|
||||
/** AI 聊天对话 列表 */
|
||||
defineOptions({ name: 'AiChat' })
|
||||
@@ -156,6 +161,8 @@ const conversationInAbortController = ref<any>() // 对话进行中 abort 控制
|
||||
const inputTimeout = ref<any>() // 处理输入中回车的定时器
|
||||
const prompt = ref<string>() // prompt
|
||||
const enableContext = ref<boolean>(true) // 是否开启上下文
|
||||
const enableWebSearch = ref<boolean>(false) // 是否开启联网搜索
|
||||
const uploadFiles = ref<string[]>([]) // 上传的文件 URL 列表
|
||||
// 接收 Stream 消息
|
||||
const receiveMessageFullText = ref('')
|
||||
const receiveMessageDisplayedText = ref('')
|
||||
@@ -197,6 +204,8 @@ const handleConversationClick = async (conversation: ChatConversationVO) => {
|
||||
scrollToBottom(true)
|
||||
// 清空输入框
|
||||
prompt.value = ''
|
||||
// 清空文件列表
|
||||
uploadFiles.value = []
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -238,6 +247,8 @@ const handleConversationCreate = async () => {
|
||||
const handleConversationCreateSuccess = async () => {
|
||||
// 创建新的对话,清空输入框
|
||||
prompt.value = ''
|
||||
// 清空文件列表
|
||||
uploadFiles.value = []
|
||||
}
|
||||
|
||||
// =========== 【消息列表】相关 ===========
|
||||
@@ -285,9 +296,18 @@ const messageList = computed(() => {
|
||||
return [
|
||||
{
|
||||
id: 0,
|
||||
conversationId: activeConversation.value.id || 0,
|
||||
type: 'system',
|
||||
content: activeConversation.value.systemMessage
|
||||
}
|
||||
userId: '',
|
||||
roleId: '',
|
||||
model: 0,
|
||||
modelId: 0,
|
||||
content: activeConversation.value.systemMessage,
|
||||
tokens: 0,
|
||||
createTime: new Date(),
|
||||
roleAvatar: '',
|
||||
userAvatar: ''
|
||||
} as ChatMessageVO
|
||||
]
|
||||
}
|
||||
return []
|
||||
@@ -395,12 +415,19 @@ const doSendMessage = async (content: string) => {
|
||||
message.error('还没创建对话,不能发送!')
|
||||
return
|
||||
}
|
||||
// 清空输入框
|
||||
|
||||
// 准备附件 URL 数组
|
||||
const attachmentUrls = [...uploadFiles.value]
|
||||
|
||||
// 清空输入框和文件列表
|
||||
prompt.value = ''
|
||||
uploadFiles.value = []
|
||||
|
||||
// 执行发送
|
||||
await doSendMessageStream({
|
||||
conversationId: activeConversationId.value,
|
||||
content: content
|
||||
content: content,
|
||||
attachmentUrls: attachmentUrls
|
||||
} as ChatMessageVO)
|
||||
}
|
||||
|
||||
@@ -420,6 +447,7 @@ const doSendMessageStream = async (userMessage: ChatMessageVO) => {
|
||||
conversationId: activeConversationId.value,
|
||||
type: 'user',
|
||||
content: userMessage.content,
|
||||
attachmentUrls: userMessage.attachmentUrls || [],
|
||||
createTime: new Date()
|
||||
} as ChatMessageVO)
|
||||
activeMessageList.value.push({
|
||||
@@ -427,6 +455,7 @@ const doSendMessageStream = async (userMessage: ChatMessageVO) => {
|
||||
conversationId: activeConversationId.value,
|
||||
type: 'assistant',
|
||||
content: '思考中...',
|
||||
reasoningContent: '',
|
||||
createTime: new Date()
|
||||
} as ChatMessageVO)
|
||||
// 1.2 滚动到最下面
|
||||
@@ -442,17 +471,23 @@ const doSendMessageStream = async (userMessage: ChatMessageVO) => {
|
||||
userMessage.content,
|
||||
conversationInAbortController.value,
|
||||
enableContext.value,
|
||||
enableWebSearch.value,
|
||||
async (res) => {
|
||||
const { code, data, msg } = JSON.parse(res.data)
|
||||
if (code !== 0) {
|
||||
message.alert(`对话异常! ${msg}`)
|
||||
// 如果未接收到消息,则进行删除
|
||||
if (receiveMessageFullText.value === '') {
|
||||
activeMessageList.value.pop()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 如果内容为空,就不处理。
|
||||
if (data.receive.content === '') {
|
||||
if (data.receive.content === '' && !data.receive.reasoningContent) {
|
||||
return
|
||||
}
|
||||
|
||||
// 首次返回需要添加一个 message 到页面,后面的都是更新
|
||||
if (isFirstChunk) {
|
||||
isFirstChunk = false
|
||||
@@ -461,22 +496,35 @@ const doSendMessageStream = async (userMessage: ChatMessageVO) => {
|
||||
activeMessageList.value.pop()
|
||||
// 更新返回的数据
|
||||
activeMessageList.value.push(data.send)
|
||||
data.send.attachmentUrls = userMessage.attachmentUrls
|
||||
activeMessageList.value.push(data.receive)
|
||||
}
|
||||
// debugger
|
||||
receiveMessageFullText.value = receiveMessageFullText.value + data.receive.content
|
||||
|
||||
// 处理 reasoningContent
|
||||
if (data.receive.reasoningContent) {
|
||||
const lastMessage = activeMessageList.value[activeMessageList.value.length - 1]
|
||||
lastMessage.reasoningContent =
|
||||
lastMessage.reasoningContent + data.receive.reasoningContent
|
||||
}
|
||||
|
||||
// 处理正常内容
|
||||
if (data.receive.content !== '') {
|
||||
receiveMessageFullText.value = receiveMessageFullText.value + data.receive.content
|
||||
}
|
||||
// 滚动到最下面
|
||||
await scrollToBottom()
|
||||
},
|
||||
(error) => {
|
||||
message.alert(`对话异常! ${error}`)
|
||||
(error: any) => {
|
||||
// 异常提示,并停止流
|
||||
message.alert(`对话异常!`)
|
||||
stopStream()
|
||||
// 需要抛出异常,禁止重试
|
||||
throw error
|
||||
},
|
||||
() => {
|
||||
stopStream()
|
||||
}
|
||||
},
|
||||
userMessage.attachmentUrls
|
||||
)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,16 @@
|
||||
<el-option v-for="item in toolList" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="引用 MCP" prop="toolIds">
|
||||
<el-select v-model="formData.mcpClientNames" placeholder="请选择 MCP" clearable multiple>
|
||||
<el-option
|
||||
v-for="dict in getStrDictOptions(DICT_TYPE.AI_MCP_CLIENT_NAME)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否公开" prop="publicStatus" v-if="!isUser">
|
||||
<el-radio-group v-model="formData.publicStatus">
|
||||
<el-radio
|
||||
@@ -80,7 +90,7 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { getIntDictOptions, getBoolDictOptions, DICT_TYPE } from '@/utils/dict'
|
||||
import { getIntDictOptions, getBoolDictOptions, DICT_TYPE, getStrDictOptions } from '@/utils/dict'
|
||||
import { ChatRoleApi, ChatRoleVO } from '@/api/ai/model/chatRole'
|
||||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
import { ModelApi, ModelVO } from '@/api/ai/model/model'
|
||||
@@ -111,7 +121,8 @@ const formData = ref({
|
||||
publicStatus: true,
|
||||
status: CommonStatusEnum.ENABLE,
|
||||
knowledgeIds: [] as number[],
|
||||
toolIds: [] as number[]
|
||||
toolIds: [] as number[],
|
||||
mcpClientNames: [] as string[]
|
||||
})
|
||||
const formRef = ref() // 表单 Ref
|
||||
const models = ref([] as ModelVO[]) // 聊天模型列表
|
||||
@@ -204,7 +215,8 @@ const resetForm = () => {
|
||||
publicStatus: true,
|
||||
status: CommonStatusEnum.ENABLE,
|
||||
knowledgeIds: [],
|
||||
toolIds: []
|
||||
toolIds: [],
|
||||
mcpClientNames: []
|
||||
}
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { copied, copy } = useClipboard() // 粘贴板
|
||||
const { copied, copy } = useClipboard({ legacy: true }) // 粘贴板
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
|
||||
@@ -178,8 +178,7 @@
|
||||
link
|
||||
type="primary"
|
||||
@click="openModelForm('update', scope.row.id)"
|
||||
v-if="hasPermiUpdate"
|
||||
:disabled="!isManagerUser(scope.row)"
|
||||
:disabled="!isManagerUser(scope.row) && !hasPermiUpdate"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
@@ -187,8 +186,7 @@
|
||||
link
|
||||
type="primary"
|
||||
@click="openModelForm('copy', scope.row.id)"
|
||||
v-if="hasPermiUpdate"
|
||||
:disabled="!isManagerUser(scope.row)"
|
||||
:disabled="!isManagerUser(scope.row) && !hasPermiUpdate"
|
||||
>
|
||||
复制
|
||||
</el-button>
|
||||
@@ -197,8 +195,7 @@
|
||||
class="!ml-5px"
|
||||
type="primary"
|
||||
@click="handleDeploy(scope.row)"
|
||||
v-if="hasPermiDeploy"
|
||||
:disabled="!isManagerUser(scope.row)"
|
||||
:disabled="!isManagerUser(scope.row) && !hasPermiDeploy"
|
||||
>
|
||||
发布
|
||||
</el-button>
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
<template>
|
||||
<el-form ref="formRef" :model="modelData" label-width="120px" class="mt-20px">
|
||||
<el-form ref="formRef" :model="modelData" label-width="130px" class="mt-20px">
|
||||
<el-form-item class="mb-20px">
|
||||
<template #label>
|
||||
<el-text size="large" tag="b">提交人权限</el-text>
|
||||
</template>
|
||||
<div class="flex flex-col">
|
||||
<el-checkbox v-model="modelData.allowCancelRunningProcess" label="允许撤销审批中的申请" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item class="mb-20px">
|
||||
<template #label>
|
||||
<el-text size="large" tag="b">审批人权限</el-text>
|
||||
</template>
|
||||
<div class="flex flex-col">
|
||||
<el-checkbox v-model="modelData.allowWithdrawTask" label="允许审批人撤回任务" />
|
||||
<div class="ml-22px">
|
||||
<el-text type="info"> 第一个审批节点通过后,提交人仍可撤销申请 </el-text>
|
||||
<el-text type="info"> 审批人可撤回正在审批节点的前一节点 </el-text>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
@@ -220,7 +228,30 @@
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item class="mb-20px">
|
||||
<template #label>
|
||||
<el-text size="large" tag="b">自定义打印模板</el-text>
|
||||
</template>
|
||||
<div class="flex flex-col w-100%">
|
||||
<div class="flex">
|
||||
<el-switch
|
||||
v-model="modelData.printTemplateSetting.enable"
|
||||
@change="handlePrintTemplateEnableChange"
|
||||
/>
|
||||
<el-button
|
||||
v-if="modelData.printTemplateSetting.enable"
|
||||
class="ml-80px"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleEditPrintTemplate"
|
||||
>
|
||||
编辑模板
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<print-template ref="printTemplateRef" @confirm="confirmPrintTemplate" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -230,36 +261,9 @@ import * as FormApi from '@/api/bpm/form'
|
||||
import { parseFormFields } from '@/components/FormCreate/src/utils'
|
||||
import { ProcessVariableEnum } from '@/components/SimpleProcessDesignerV2/src/consts'
|
||||
import HttpRequestSetting from '@/components/SimpleProcessDesignerV2/src/nodes-config/components/HttpRequestSetting.vue'
|
||||
import PrintTemplate from './PrintTemplate/Index.vue'
|
||||
|
||||
const modelData = defineModel<any>()
|
||||
const formFields = ref<string[]>([])
|
||||
|
||||
const props = defineProps({
|
||||
// 流程表单 ID
|
||||
modelFormId: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: undefined,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// 监听 modelFormId 变化
|
||||
watch(
|
||||
() => props.modelFormId,
|
||||
async (newVal) => {
|
||||
if (newVal) {
|
||||
const form = await FormApi.getForm(newVal);
|
||||
formFields.value = form?.fields;
|
||||
} else {
|
||||
// 如果 modelFormId 为空,清空表单字段
|
||||
formFields.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
// 暴露给子组件使用
|
||||
provide('formFields', formFields)
|
||||
|
||||
/** 自定义 ID 流程编码 */
|
||||
const timeOptions = ref([
|
||||
@@ -374,10 +378,10 @@ const handleTaskAfterTriggerEnableChange = (val: boolean | string | number) => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 表单选项 */
|
||||
const formField = ref<Array<{ field: string; title: string }>>([])
|
||||
/** 已解析表单字段 */
|
||||
const formFields = ref<Array<{ field: string; title: string }>>([])
|
||||
const formFieldOptions4Title = computed(() => {
|
||||
let cloneFormField = formField.value.map((item) => {
|
||||
let cloneFormField = formFields.value.map((item) => {
|
||||
return {
|
||||
label: item.title,
|
||||
value: item.field
|
||||
@@ -399,7 +403,7 @@ const formFieldOptions4Title = computed(() => {
|
||||
return cloneFormField
|
||||
})
|
||||
const formFieldOptions4Summary = computed(() => {
|
||||
return formField.value.map((item) => {
|
||||
return formFields.value.map((item) => {
|
||||
return {
|
||||
label: item.title,
|
||||
value: item.field
|
||||
@@ -407,6 +411,12 @@ const formFieldOptions4Summary = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
/** 未解析的表单字段 */
|
||||
const unParsedFormFields = ref<string[]>([])
|
||||
/** 暴露给子组件 HttpRequestSetting 使用 */
|
||||
provide('formFields', unParsedFormFields)
|
||||
provide('formFieldsObj', formFields)
|
||||
|
||||
/** 兼容以前未配置更多设置的流程 */
|
||||
const initData = () => {
|
||||
if (!modelData.value.processIdRule) {
|
||||
@@ -445,6 +455,14 @@ const initData = () => {
|
||||
if (modelData.value.taskAfterTriggerSetting) {
|
||||
taskAfterTriggerEnable.value = true
|
||||
}
|
||||
if (modelData.value.allowWithdrawTask) {
|
||||
modelData.value.allowWithdrawTask = false
|
||||
}
|
||||
if (!modelData.value.printTemplateSetting) {
|
||||
modelData.value.printTemplateSetting = {
|
||||
enable: false
|
||||
}
|
||||
}
|
||||
}
|
||||
defineExpose({ initData })
|
||||
|
||||
@@ -456,15 +474,34 @@ watch(
|
||||
const data = await FormApi.getForm(newFormId)
|
||||
const result: Array<{ field: string; title: string }> = []
|
||||
if (data.fields) {
|
||||
unParsedFormFields.value = data.fields
|
||||
data.fields.forEach((fieldStr: string) => {
|
||||
parseFormFields(JSON.parse(fieldStr), result)
|
||||
})
|
||||
}
|
||||
formField.value = result
|
||||
formFields.value = result
|
||||
} else {
|
||||
formField.value = []
|
||||
formFields.value = []
|
||||
unParsedFormFields.value = []
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const defaultTemplate =
|
||||
'<p style="text-align: center;"><span data-w-e-type="mention" data-w-e-is-void="" data-w-e-is-inline="" data-value="流程名称" data-info="%7B%22id%22%3A%22processName%22%7D">@流程名称</span></p><p style="text-align: right;">打印人:<span data-w-e-type="mention" data-w-e-is-void="" data-w-e-is-inline="" data-value="打印人" data-info="%7B%22id%22%3A%22printUser%22%7D">@打印人</span></p><p style="text-align: right;">流程编号:<span data-w-e-type="mention" data-w-e-is-void="" data-w-e-is-inline="" data-value="流程编号" data-info="%7B%22id%22%3A%22processNum%22%7D">@流程编号</span> 打印时间:<span data-w-e-type="mention" data-w-e-is-void="" data-w-e-is-inline="" data-value="打印时间" data-info="%7B%22id%22%3A%22printTime%22%7D">@打印时间</span></p><table style="width: 100%;"><tbody><tr><td colSpan="1" rowSpan="1" width="auto">发起人</td><td colSpan="1" rowSpan="1" width="auto"><span data-w-e-type="mention" data-w-e-is-void data-w-e-is-inline data-value="发起人" data-info="%7B%22id%22%3A%22startUser%22%7D">@发起人</span></td><td colSpan="1" rowSpan="1" width="auto">发起时间</td><td colSpan="1" rowSpan="1" width="auto"><span data-w-e-type="mention" data-w-e-is-void data-w-e-is-inline data-value="发起时间" data-info="%7B%22id%22%3A%22startTime%22%7D">@发起时间</span></td></tr><tr><td colSpan="1" rowSpan="1" width="auto">所属部门</td><td colSpan="1" rowSpan="1" width="auto"><span data-w-e-type="mention" data-w-e-is-void data-w-e-is-inline data-value="发起人部门" data-info="%7B%22id%22%3A%22startUserDept%22%7D">@发起人部门</span></td><td colSpan="1" rowSpan="1" width="auto">流程状态</td><td colSpan="1" rowSpan="1" width="auto"><span data-w-e-type="mention" data-w-e-is-void data-w-e-is-inline data-value="流程状态" data-info="%7B%22id%22%3A%22processStatus%22%7D">@流程状态</span></td></tr></tbody></table><p><span data-w-e-type="process-record" data-w-e-is-void data-w-e-is-inline>流程记录</span></p>'
|
||||
const handlePrintTemplateEnableChange = (val: boolean) => {
|
||||
if (val) {
|
||||
if (!modelData.value.printTemplateSetting.template) {
|
||||
modelData.value.printTemplateSetting.template = defaultTemplate
|
||||
}
|
||||
}
|
||||
}
|
||||
const printTemplateRef = ref()
|
||||
const handleEditPrintTemplate = () => {
|
||||
printTemplateRef.value.open(modelData.value.printTemplateSetting.template)
|
||||
}
|
||||
const confirmPrintTemplate = (template: any) => {
|
||||
modelData.value.printTemplateSetting.template = template
|
||||
}
|
||||
</script>
|
||||
|
||||
116
src/views/bpm/model/form/PrintTemplate/Index.vue
Normal file
116
src/views/bpm/model/form/PrintTemplate/Index.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { Editor, Toolbar } from '@wangeditor-next/editor-for-vue'
|
||||
import { IDomEditor } from '@wangeditor-next/editor'
|
||||
import MentionModal from './MentionModal.vue'
|
||||
|
||||
const emit = defineEmits(['confirm'])
|
||||
|
||||
// @mention 相关
|
||||
const isShowModal = ref(false)
|
||||
const showModal = () => {
|
||||
isShowModal.value = true
|
||||
}
|
||||
const hideModal = () => {
|
||||
isShowModal.value = false
|
||||
}
|
||||
const insertMention = (id: any, name: any) => {
|
||||
const mentionNode = {
|
||||
type: 'mention',
|
||||
value: name,
|
||||
info: { id },
|
||||
children: [{ text: '' }]
|
||||
}
|
||||
const editor = editorRef.value
|
||||
if (editor) {
|
||||
editor.restoreSelection()
|
||||
editor.deleteBackward('character')
|
||||
editor.insertNode(mentionNode)
|
||||
editor.move(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Dialog 相关
|
||||
const dialogVisible = ref(false)
|
||||
const open = async (template: string) => {
|
||||
dialogVisible.value = true
|
||||
valueHtml.value = template
|
||||
}
|
||||
defineExpose({ open })
|
||||
const handleConfirm = () => {
|
||||
emit('confirm', valueHtml.value)
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
// Editor 相关
|
||||
const editorRef = shallowRef<IDomEditor>()
|
||||
const editorId = ref('wangEditor-1')
|
||||
const toolbarConfig = {
|
||||
excludeKeys: ['group-video'],
|
||||
insertKeys: {
|
||||
index: 31,
|
||||
keys: ['ProcessRecordMenu']
|
||||
}
|
||||
}
|
||||
const editorConfig = {
|
||||
placeholder: '请输入内容...',
|
||||
EXTEND_CONF: {
|
||||
mentionConfig: {
|
||||
showModal,
|
||||
hideModal
|
||||
}
|
||||
}
|
||||
}
|
||||
const valueHtml = ref()
|
||||
const handleCreated = (editor: IDomEditor) => {
|
||||
editorRef.value = editor
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor == null) {
|
||||
return
|
||||
}
|
||||
editor.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="dialogVisible" title="自定义模板" fullscreen>
|
||||
<div style="margin: 0 10px">
|
||||
<el-alert
|
||||
title="输入 @ 可选择插入流程表单选项和默认选项"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
<!-- TODO @unocss 简化 style -->
|
||||
<div style=" margin: 10px;border: 1px solid #ccc">
|
||||
<Toolbar
|
||||
style="border-bottom: 1px solid #ccc"
|
||||
:editor="editorRef"
|
||||
:editorId="editorId"
|
||||
:defaultConfig="toolbarConfig"
|
||||
/>
|
||||
<Editor
|
||||
style="height: 500px; overflow-y: hidden"
|
||||
v-model="valueHtml"
|
||||
:defaultConfig="editorConfig"
|
||||
:editorId="editorId"
|
||||
@on-created="handleCreated"
|
||||
/>
|
||||
<MentionModal
|
||||
v-if="isShowModal"
|
||||
@hide-mention-modal="hideModal"
|
||||
@insert-mention="insertMention"
|
||||
/>
|
||||
</div>
|
||||
<div style=" float: right;margin-right: 10px">
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
<el-button type="primary" @click="handleConfirm">确 定</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style src="@wangeditor-next/editor/dist/css/style.css"></style>
|
||||
110
src/views/bpm/model/form/PrintTemplate/MentionModal.vue
Normal file
110
src/views/bpm/model/form/PrintTemplate/MentionModal.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits(['hideMentionModal', 'insertMention'])
|
||||
|
||||
const inputRef = ref()
|
||||
const top = ref('')
|
||||
const left = ref('')
|
||||
const searchVal = ref('')
|
||||
const list = ref([
|
||||
{ id: 'startUser', name: '发起人' },
|
||||
{ id: 'startUserDept', name: '发起人部门' },
|
||||
{ id: 'processName', name: '流程名称' },
|
||||
{ id: 'processNum', name: '流程编号' },
|
||||
{ id: 'startTime', name: '发起时间' },
|
||||
{ id: 'endTime', name: '结束时间' },
|
||||
{ id: 'processStatus', name: '流程状态' },
|
||||
{ id: 'printUser', name: '打印人' },
|
||||
{ id: 'printTime', name: '打印时间' }
|
||||
])
|
||||
const searchedList = computed(() => {
|
||||
const searchValStr = searchVal.value.trim().toLowerCase()
|
||||
return list.value.filter((item) => {
|
||||
const name = item.name.toLowerCase()
|
||||
return name.indexOf(searchValStr) >= 0
|
||||
})
|
||||
})
|
||||
const inputKeyupHandler = (event: any) => {
|
||||
if (event.key === 'Escape') {
|
||||
emit('hideMentionModal')
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
const firstOne = searchedList.value[0]
|
||||
if (firstOne) {
|
||||
const { id, name } = firstOne
|
||||
insertMentionHandler(id, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
const insertMentionHandler = (id: any, name: any) => {
|
||||
emit('insertMention', id, name)
|
||||
emit('hideMentionModal')
|
||||
}
|
||||
|
||||
const formFields = inject<any>('formFieldsObj')
|
||||
onMounted(() => {
|
||||
if (formFields.value && formFields.value.length > 0) {
|
||||
const cloneFormField = formFields.value.map((item) => {
|
||||
return {
|
||||
name: '[表单]' + item.title,
|
||||
id: item.field
|
||||
}
|
||||
})
|
||||
list.value.push(...cloneFormField)
|
||||
}
|
||||
const domSelection = document.getSelection()
|
||||
const domRange = domSelection?.getRangeAt(0)
|
||||
if (domRange == null) return
|
||||
const rect = domRange.getBoundingClientRect()
|
||||
|
||||
top.value = `${rect.top + 20}px`
|
||||
left.value = `${rect.left + 5}px`
|
||||
|
||||
inputRef.value.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="mention-modal" :style="{ top: top, left: left }">
|
||||
<!-- TODO @lesan:css 可以用 unocss 哇? -->
|
||||
<input id="mention-input" v-model="searchVal" ref="inputRef" @keyup="inputKeyupHandler" />
|
||||
<ul id="mention-list">
|
||||
<li
|
||||
v-for="item in searchedList"
|
||||
:key="item.id"
|
||||
@click="insertMentionHandler(item.id, item.name)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#mention-modal {
|
||||
position: absolute;
|
||||
border: 1px solid #ccc;
|
||||
background-color: #fff;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#mention-modal input {
|
||||
width: 100px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#mention-modal ul {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#mention-modal ul li {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 3px 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#mention-modal ul li:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
9
src/views/bpm/model/form/PrintTemplate/index.ts
Normal file
9
src/views/bpm/model/form/PrintTemplate/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Boot } from '@wangeditor-next/editor'
|
||||
import processRecordModule from './module'
|
||||
import mentionModule from '@wangeditor-next/plugin-mention'
|
||||
|
||||
// 注册:要在创建编辑器之前注册,且只能注册一次,不可重复注册
|
||||
export const setupWangEditorPlugin = () => {
|
||||
Boot.registerModule(processRecordModule)
|
||||
Boot.registerModule(mentionModule)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { SlateElement } from '@wangeditor-next/editor'
|
||||
|
||||
function processRecordToHtml(_elem: SlateElement, _childrenHtml: string): string {
|
||||
return `<span data-w-e-type="process-record" data-w-e-is-void data-w-e-is-inline>流程记录</span>`
|
||||
}
|
||||
|
||||
const conf = {
|
||||
type: 'process-record',
|
||||
elemToHtml: processRecordToHtml
|
||||
}
|
||||
|
||||
export default conf
|
||||
17
src/views/bpm/model/form/PrintTemplate/module/index.ts
Normal file
17
src/views/bpm/model/form/PrintTemplate/module/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { IModuleConf } from '@wangeditor-next/editor'
|
||||
import withProcessRecord from './plugin'
|
||||
import renderElemConf from './render-elem'
|
||||
import elemToHtmlConf from './elem-to-html'
|
||||
import parseHtmlConf from './parse-elem-html'
|
||||
import processRecordMenu from './menu/ProcessRecordMenu'
|
||||
|
||||
// 可参考 wangEditor 官方文档进行自定义扩展插件:https://www.wangeditor.com/v5/development.html#%E5%AE%9A%E4%B9%89%E6%96%B0%E5%85%83%E7%B4%A0
|
||||
const module: Partial<IModuleConf> = {
|
||||
editorPlugin: withProcessRecord,
|
||||
renderElems: [renderElemConf],
|
||||
elemsToHtml: [elemToHtmlConf],
|
||||
parseElemsHtml: [parseHtmlConf],
|
||||
menus: [processRecordMenu]
|
||||
}
|
||||
|
||||
export default module
|
||||
@@ -0,0 +1,42 @@
|
||||
import { IButtonMenu, IDomEditor } from '@wangeditor-next/editor'
|
||||
|
||||
class ProcessRecordMenu implements IButtonMenu {
|
||||
readonly tag: string
|
||||
readonly title: string
|
||||
|
||||
constructor() {
|
||||
this.title = '流程记录'
|
||||
this.tag = 'button'
|
||||
}
|
||||
|
||||
getValue(_editor: IDomEditor): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
isActive(_editor: IDomEditor): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
isDisabled(_editor: IDomEditor): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
exec(editor: IDomEditor, _value: string) {
|
||||
if (this.isDisabled(editor)) return
|
||||
const processRecordElem = {
|
||||
type: 'process-record',
|
||||
children: [{ text: '' }]
|
||||
}
|
||||
editor.insertNode(processRecordElem)
|
||||
editor.move(1)
|
||||
}
|
||||
}
|
||||
|
||||
const ProcessRecordMenuConf = {
|
||||
key: 'ProcessRecordMenu',
|
||||
factory() {
|
||||
return new ProcessRecordMenu()
|
||||
}
|
||||
}
|
||||
|
||||
export default ProcessRecordMenuConf
|
||||
@@ -0,0 +1,33 @@
|
||||
import { DOMElement } from './utils/dom'
|
||||
import { IDomEditor, SlateDescendant, SlateElement } from '@wangeditor-next/editor'
|
||||
|
||||
/**
|
||||
* 解析 HTML 字符串,生成“附件”元素
|
||||
* @param domElem HTML 对应的 DOM Element
|
||||
* @param children 子节点
|
||||
* @param editor editor 实例
|
||||
* @returns “附件”元素,如上文的 myResume
|
||||
*/
|
||||
function parseHtml(
|
||||
_domElem: DOMElement,
|
||||
_children: SlateDescendant[],
|
||||
_editor: IDomEditor
|
||||
): SlateElement {
|
||||
// TS 语法
|
||||
|
||||
|
||||
// 生成“流程记录”元素(按照此前约定的数据结构)
|
||||
const processRecord = {
|
||||
type: 'process-record',
|
||||
children: [{ text: '' }], // void node 必须有 children ,其中有一个空字符串,重要!!!
|
||||
}
|
||||
|
||||
return processRecord
|
||||
}
|
||||
|
||||
const parseHtmlConf = {
|
||||
selector: 'span[data-w-e-type="process-record"]',
|
||||
parseElemHtml: parseHtml
|
||||
}
|
||||
|
||||
export default parseHtmlConf
|
||||
28
src/views/bpm/model/form/PrintTemplate/module/plugin.ts
Normal file
28
src/views/bpm/model/form/PrintTemplate/module/plugin.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { DomEditor, IDomEditor } from '@wangeditor-next/editor'
|
||||
|
||||
function withProcessRecord<T extends IDomEditor>(editor: T) {
|
||||
const { isInline, isVoid } = editor
|
||||
const newEditor = editor
|
||||
|
||||
newEditor.isInline = (elem) => {
|
||||
const type = DomEditor.getNodeType(elem)
|
||||
if (type === 'process-record') {
|
||||
return true
|
||||
}
|
||||
|
||||
return isInline(elem)
|
||||
}
|
||||
|
||||
newEditor.isVoid = (elem) => {
|
||||
const type = DomEditor.getNodeType(elem)
|
||||
if (type === 'process-record') {
|
||||
return true
|
||||
}
|
||||
|
||||
return isVoid(elem)
|
||||
}
|
||||
|
||||
return newEditor
|
||||
}
|
||||
|
||||
export default withProcessRecord
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user