Merge branch 'master' of https://gitee.com/yudaocode/yudao-ui-admin-vue3 into feature/iot

# Conflicts:
#	pnpm-lock.yaml
#	src/views/iot/device/device/index.vue
pull/856/head
YunaiV 2026-01-20 16:56:44 +08:00
commit 3821b32b03
203 changed files with 7488 additions and 4157 deletions

9
.env
View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

@ -200,18 +200,19 @@
### 微信公众号
| | 功能 | 描述 |
|-----|--------|-------------------------------|
| 🚀 | 账号管理 | 配置接入的微信公众号,可支持多个公众号 |
| 🚀 | 数据统计 | 统计公众号的用户增减、累计用户、消息概况、接口分析等数据 |
| 🚀 | 粉丝管理 | 查看已关注、取关的粉丝列表,可对粉丝进行同步、打标签等操作 |
| 🚀 | 消息管理 | 查看粉丝发送的消息列表,可主动回复粉丝消息 |
| 🚀 | 自动回复 | 自动回复粉丝发送的消息,支持关注回复、消息回复、关键字回复 |
| 🚀 | 标签管理 | 对公众号的标签进行创建、查询、修改、删除等操作 |
| 🚀 | 菜单管理 | 自定义公众号的菜单,也可以从公众号同步菜单 |
| 🚀 | 素材管理 | 管理公众号的图片、语音、视频等素材,支持在线播放语音、视频 |
| 🚀 | 图文草稿箱 | 新增常用的图文素材到草稿箱,可发布到公众号 |
| 🚀 | 图文发表记录 | 查看已发布成功的图文素材,支持删除操作 |
| | 功能 | 描述 |
|----|--------|-------------------------------|
| 🚀 | 账号管理 | 配置接入的微信公众号,可支持多个公众号 |
| 🚀 | 数据统计 | 统计公众号的用户增减、累计用户、消息概况、接口分析等数据 |
| 🚀 | 粉丝管理 | 查看已关注、取关的粉丝列表,可对粉丝进行同步、打标签等操作 |
| 🚀 | 消息管理 | 查看粉丝发送的消息列表,可主动回复粉丝消息 |
| 🚀 | 模版消息 | 配置和发送模版消息,用于向粉丝推送通知类消息 |
| 🚀 | 自动回复 | 自动回复粉丝发送的消息,支持关注回复、消息回复、关键字回复 |
| 🚀 | 标签管理 | 对公众号的标签进行创建、查询、修改、删除等操作 |
| 🚀 | 菜单管理 | 自定义公众号的菜单,也可以从公众号同步菜单 |
| 🚀 | 素材管理 | 管理公众号的图片、语音、视频等素材,支持在线播放语音、视频 |
| 🚀 | 图文草稿箱 | 新增常用的图文素材到草稿箱,可发布到公众号 |
| 🚀 | 图文发表记录 | 查看已发布成功的图文素材,支持删除操作 |
### 商城系统

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "yudao-ui-admin-vue3",
"version": "2025.08-snapshot",
"version": "2025.12-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",

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -6,6 +6,7 @@ export type ProcessDefinitionVO = {
deploymentTIme: string
suspensionState: number
formType?: number
formCustomCreatePath?: string
}
export type ModelVO = {

View File

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

View File

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

View File

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

View File

@ -12,6 +12,8 @@ export interface FileClientConfig {
accessKey?: string
accessSecret?: string
enablePathStyleAccess?: boolean
enablePublicAccess?: boolean
region?: string
domain: string
}

View File

@ -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
}
})
}
// 注册

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

View File

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

View File

@ -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(',') }
})
}
// 发送邮件

View File

@ -8,6 +8,7 @@ export interface SocialClientVO {
clientId: string
clientSecret: string
agentId: string
publicKey: string
status: number
}

View File

@ -12,6 +12,7 @@ export interface TenantVO {
password: string
expireTime: Date
accountCount: number
websites: string[]
createTime: Date
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -56,6 +56,11 @@ export const useApiSelect = (option: ApiSelectProps) => {
remoteField: {
type: String,
default: 'label'
},
// 返回值类型用于部门选择器等id 返回 IDname 返回名称
returnType: {
type: String,
default: 'id'
}
},
setup(props) {
@ -119,10 +124,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}] 返回结果不是一个数组`)

View File

@ -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[] // 事件配置
}

View File

@ -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) => {
// 插入组件规则
@ -56,7 +57,19 @@ export const useFormCreateDesigner = async (designer: Ref) => {
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' }
]
}
]
})
const dictSelectRule = useDictSelectRule()
const apiSelectRule0 = useSelectRule({
@ -93,9 +106,60 @@ export const useFormCreateDesigner = async (designer: Ref) => {
designer.value?.addMenu(menu)
}
/**
* ID
* ID
*
* issuehttps://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 }
)
})
}

View File

@ -71,6 +71,16 @@ const props = defineProps({
center: propTypes.string.def('')
})
watch(
() => props.center,
(newVal, oldVal) => {
if (newVal) {
// center mark
regeoCode(newVal)
}
}
)
/** 加载百度地图 */
const loadMap = () => {
state.address = ''
@ -101,11 +111,6 @@ const loadMap = () => {
regeoCode(state.lonLat)
})
}
// TODO @super
if (props.center) {
regeoCode(props.center)
}
}
}
@ -155,8 +160,8 @@ const autoSearch = (queryValue: string) => {
state.loading = false
const temp: any[] = []
if (results && results.getPoi) {
const pois = results.getPoi()
if (results && results._pois) {
const pois = results._pois
pois.forEach((p: any) => {
const point = p.point
if (point && point.lng && point.lat) {

View File

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

View File

@ -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() //

View File

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

View File

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

View File

@ -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
}
/**
*
*/

View File

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

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

View File

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

View File

@ -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': '展开池',

View File

@ -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" />
<!-- 相关 issuehttps://gitee.com/yudaocode/yudao-ui-admin-vue3/issues/ICNRW2 -->
<TimeEventConfig :businessObject="elementBusinessObject" :key="elementId" />
</el-collapse-item>
</el-collapse>
</div>

View File

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

View File

@ -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
// 使BPMNVue
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)
)
}

View File

@ -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
// 使BPMNVue
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)
)
}

View File

@ -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
// 使BPMNVue
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
})
}

View File

@ -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">
<!-- 补充编辑移除功能相关 issuehttps://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()
})

View File

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

View File

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

View File

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

View File

@ -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) {
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 导出

View File

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

View File

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

View File

@ -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` : '',

View File

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

View File

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

View File

@ -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 报错
}
}

View File

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

View File

@ -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()
// 异步加载字典
// 另外,间接 issuehttps://gitee.com/yudaocode/yudao-ui-admin-vue3/issues/ID9FLI
if (!dictStore.getIsSetDict) {
await dictStore.setDictMap()
dictStore.setDictMap().then()
}
if (!userStore.getIsSetUser) {
isRelogin.show = true

View File

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

View File

@ -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) => {

View File

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

View File

@ -227,6 +227,7 @@ 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 联网方式

231
src/utils/encrypt.ts Normal file
View 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
View 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'
}

View File

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

View File

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

View File

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

View File

@ -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 === '') {

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -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()
// ()

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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> &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;打印时间:<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>

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

View 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 @lesancss 可以用 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>

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

View File

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

View 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

View File

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

View File

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

View 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

View File

@ -0,0 +1,73 @@
import { h, VNode } from 'snabbdom'
import { DomEditor, IDomEditor, SlateElement } from '@wangeditor-next/editor'
function renderProcessRecord(
elem: SlateElement,
_children: VNode[] | null,
editor: IDomEditor
): VNode {
const selected = DomEditor.isNodeSelected(editor, elem)
return h(
'table',
{
props: {
contentEditable: false
},
style: {
width: '100%',
border: selected ? '2px solid var(--w-e-textarea-selected-border-color)' : ''
}
},
[
h('thead', [h('tr', [h('th', { attrs: { colSpan: 3 } }, '流程记录')])]),
h('tbody', [
h('tr', [
h('td', [
h(
'span',
{
props: {
contentEditable: false
},
style: {
marginLeft: '3px',
marginRight: '3px',
backgroundColor: 'var(--w-e-textarea-slight-bg-color)',
borderRadius: '3px',
padding: '0 3px'
}
},
`节点`
)
]),
h('td', [
h(
'span',
{
props: {
contentEditable: false
},
style: {
marginLeft: '3px',
marginRight: '3px',
backgroundColor: 'var(--w-e-textarea-slight-bg-color)',
borderRadius: '3px',
padding: '0 3px'
}
},
`操作`
)
])
])
])
]
)
}
const conf = {
type: 'process-record',
renderElem: renderProcessRecord
}
export default conf

View File

@ -0,0 +1,21 @@
import $, { append, on, hide, click } from 'dom7'
if (hide) $.fn.hide = hide
if (append) $.fn.append = append
if (click) $.fn.click = click
if (on) $.fn.on = on
export { Dom7Array } from 'dom7'
export default $
// COMPAT: This is required to prevent TypeScript aliases from doing some very
// weird things for Slate's types with the same name as globals. (2019/11/27)
// https://github.com/microsoft/TypeScript/issues/35002
import DOMNode = globalThis.Node
import DOMComment = globalThis.Comment
import DOMElement = globalThis.Element
import DOMText = globalThis.Text
import DOMRange = globalThis.Range
import DOMSelection = globalThis.Selection
import DOMStaticRange = globalThis.StaticRange
export { DOMNode, DOMComment, DOMElement, DOMText, DOMRange, DOMSelection, DOMStaticRange }

View File

@ -77,10 +77,7 @@
<!-- 第四步更多设置 -->
<div v-show="currentStep === 3" class="mx-auto w-700px">
<ExtraSettings
ref="extraSettingsRef"
v-model="formData"
:model-form-id="formData.formId"/>
<ExtraSettings ref="extraSettingsRef" v-model="formData" />
</div>
</div>
</div>
@ -176,6 +173,10 @@ const formData: any = ref({
summarySetting: {
enable: false,
summary: []
},
allowWithdrawTask: false,
printTemplateSetting: {
enable: false
}
})

View File

@ -79,6 +79,7 @@ defineOptions({ name: 'BpmOALeaveCreate' })
const message = useMessage() //
const { delView } = useTagsViewStore() //
const { push, currentRoute } = useRouter() //
const { query } = useRoute() //
const formLoading = ref(false) // 12
const formData = ref({
@ -190,6 +191,26 @@ const daysDifference = () => {
return Math.floor(diffTime / oneDay)
}
/** 获取请假数据,用于重新发起时自动填充 */
const getDetail = async (id: number) => {
try {
formLoading.value = true
const data = await LeaveApi.getLeave(id)
if (!data) {
message.error('重新发起请假失败,原因:请假数据不存在')
return
}
formData.value = {
type: data.type,
reason: data.reason,
startTime: data.startTime,
endTime: data.endTime
}
} finally {
formLoading.value = false
}
}
/** 初始化 */
onMounted(async () => {
// TODO @ getApprovalDetail
@ -205,6 +226,11 @@ onMounted(async () => {
processDefinitionId.value = processDefinitionDetail.id
startUserSelectTasks.value = processDefinitionDetail.startUserSelectTasks
//
if (query.id) {
await getDetail(Number(query.id))
}
//
await getApprovalDetail()
})

View File

@ -140,6 +140,15 @@
>
取消
</el-button>
<el-button
v-if="scope.row.status !== 1"
v-hasPermi="['bpm:oa-leave:create']"
link
type="primary"
@click="handleReCreate(scope.row)"
>
重新发起
</el-button>
</template>
</el-table-column>
</el-table>
@ -206,6 +215,16 @@ const handleCreate = () => {
router.push({ name: 'OALeaveCreate' })
}
/** 重新发起操作 */
const handleReCreate = (row: LeaveApi.LeaveVO) => {
router.push({
name: 'OALeaveCreate',
query: {
id: row.id
}
})
}
/** 详情操作 */
const handleDetail = (row: LeaveApi.LeaveVO) => {
router.push({
@ -242,7 +261,6 @@ const handleProcessDetail = (row) => {
})
}
// fix:
watch(
() => router.currentRoute.value,
() => {

View File

@ -88,6 +88,7 @@ import { useTagsViewStore } from '@/store/modules/tagsView'
import * as ProcessInstanceApi from '@/api/bpm/processInstance'
import * as DefinitionApi from '@/api/bpm/definition'
import { ApprovalNodeInfo } from '@/api/bpm/processInstance'
import formCreate from '@form-create/element-ui'
defineOptions({ name: 'ProcessDefinitionDetail' })
const props = defineProps<{
@ -127,7 +128,8 @@ const initProcessInfo = async (row: any, formVariables?: any) => {
// formVariables row.formFields
// formVariables
//
const allowedFields = decodeFields(row.formFields).map((fieldObj: any) => fieldObj.field)
const formApi = formCreate.create(decodeFields(row.formFields))
const allowedFields = formApi.fields()
for (const key in formVariables) {
if (!allowedFields.includes(key)) {
delete formVariables[key]

View File

@ -0,0 +1,234 @@
<script setup lang="ts">
import * as ProcessInstanceApi from '@/api/bpm/processInstance'
import { useUserStore } from '@/store/modules/user'
import { formatDate } from '@/utils/formatTime'
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
import { decodeFields } from '@/utils/formCreate'
const userStore = useUserStore()
const visible = ref(false)
const loading = ref(false)
const printData = ref()
const userName = computed(() => userStore.user.nickname ?? '')
const printTime = ref(formatDate(new Date(), 'YYYY-MM-DD HH:mm'))
const formFields = ref()
const printDataMap = ref({})
const open = async (id: string) => {
loading.value = true
try {
printData.value = await ProcessInstanceApi.getProcessInstancePrintData(id)
initPrintDataMap()
parseFormFields()
} finally {
loading.value = false
}
visible.value = true
}
defineExpose({ open })
const parseFormFields = () => {
if (!printData.value) return
const formFieldsObj = decodeFields(
printData.value.processInstance.processDefinition?.formFields || []
)
const processVariables = printData.value.processInstance.formVariables
let res: any = []
for (const item of formFieldsObj) {
const id = item['field']
const name = item['title']
const variable = processVariables[item['field']]
let html = variable
switch (item['type']) {
case 'UploadImg': {
let imgEl = document.createElement('img')
imgEl.setAttribute('src', variable)
imgEl.setAttribute('style', 'max-width: 600px;')
html = imgEl.outerHTML
break
}
case 'radio':
case 'checkbox':
case 'select': {
const options = item['options'] || []
const temp: any = []
if (Array.isArray(variable)) {
const labels = options.filter((o) => variable.includes(o.value)).map((o) => o.label)
temp.push(...labels)
} else {
const opt = options.find((o) => o.value === variable)
temp.push(opt.label)
}
html = temp.join(',')
}
// TODO
}
printDataMap.value[item['field']] = html
res.push({ id, name, html })
}
formFields.value = res
}
const initPrintDataMap = () => {
printDataMap.value['startUser'] = printData.value.processInstance.startUser.nickname
printDataMap.value['startUserDept'] = printData.value.processInstance.startUser.deptName
printDataMap.value['processName'] = printData.value.processInstance.name
printDataMap.value['processNum'] = printData.value.processInstance.id
printDataMap.value['startTime'] = formatDate(printData.value.processInstance.startTime)
printDataMap.value['endTime'] = formatDate(printData.value.processInstance.endTime)
printDataMap.value['processStatus'] = getDictLabel(
DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS,
printData.value.processInstance.status
)
printDataMap.value['printUser'] = userName.value
printDataMap.value['printTime'] = printTime.value
}
const getPrintTemplateHTML = () => {
const parser = new DOMParser()
let doc = parser.parseFromString(printData.value.printTemplateHtml, 'text/html')
// table border
let tables = doc.querySelectorAll('table')
tables.forEach((item) => {
item.setAttribute('border', '1')
item.setAttribute('style', (item.getAttribute('style') || '') + 'border-collapse:collapse;')
})
// mentions
let mentions = doc.querySelectorAll('[data-w-e-type="mention"]')
mentions.forEach((item) => {
const mentionId = JSON.parse(decodeURIComponent(item.getAttribute('data-info') ?? ''))['id']
item.innerHTML = printDataMap.value[mentionId] ?? ''
})
//
let processRecords = doc.querySelectorAll('[data-w-e-type="process-record"]')
let processRecordTable: Element = document.createElement('table')
if (processRecords.length > 0) {
// html
processRecordTable.setAttribute('border', '1')
processRecordTable.setAttribute('style', 'width:100%;border-collapse:collapse;')
const headTr = document.createElement('tr')
const headTd = document.createElement('td')
headTd.setAttribute('colspan', '2')
headTd.setAttribute('width', 'auto')
headTd.setAttribute('style', 'text-align: center;')
headTd.innerHTML = '流程节点'
headTr.appendChild(headTd)
processRecordTable.appendChild(headTr)
printData.value.tasks.forEach((item) => {
const tr = document.createElement('tr')
const td1 = document.createElement('td')
td1.innerHTML = item.name
const td2 = document.createElement('td')
td2.innerHTML = item.description
tr.appendChild(td1)
tr.appendChild(td2)
processRecordTable.appendChild(tr)
})
}
processRecords.forEach((item) => {
item.innerHTML = processRecordTable.outerHTML
})
// html
return doc.body.innerHTML
}
const printObj = ref({
id: 'printDivTag',
popTitle: '&nbsp',
extraCss: '/print.css',
extraHead: '',
zIndex: 20003
})
</script>
<template>
<el-dialog v-loading="loading" v-model="visible" :show-close="false">
<div id="printDivTag" style="word-break: break-all">
<div v-if="printData.printTemplateEnable" v-html="getPrintTemplateHTML()"></div>
<div v-else>
<h2 class="text-center">{{ printData.processInstance.name }}</h2>
<div class="text-right text-15px">{{ '打印人员: ' + userName }}</div>
<div class="flex justify-between">
<div class="text-15px">{{ '流程编号: ' + printData.processInstance.id }}</div>
<div class="text-15px">{{ '打印时间: ' + printTime }}</div>
</div>
<table class="mt-20px w-100%" border="1" style="border-collapse: collapse">
<tbody>
<tr>
<td class="p-5px w-25%">发起人</td>
<td class="p-5px w-25%">{{ printData.processInstance.startUser.nickname }}</td>
<td class="p-5px w-25%">发起时间</td>
<td class="p-5px w-25%">{{ formatDate(printData.processInstance.startTime) }}</td>
</tr>
<tr>
<td class="p-5px w-25%">所属部门</td>
<td class="p-5px w-25%">{{ printData.processInstance.startUser.deptName }}</td>
<td class="p-5px w-25%">流程状态</td>
<td class="p-5px w-25%">
{{
getDictLabel(
DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS,
printData.processInstance.status
)
}}
</td>
</tr>
<tr>
<td class="p-5px w-100% text-center" colspan="4">
<h4>表单内容</h4>
</td>
</tr>
<tr v-for="item in formFields" :key="item.id">
<td class="p-5px w-20%">
{{ item.name }}
</td>
<td class="p-5px w-80%" colspan="3">
<div v-html="item.html"></div>
</td>
</tr>
<tr>
<td class="p-5px w-100% text-center" colspan="4">
<h4>流程节点</h4>
</td>
</tr>
<tr v-for="item in printData.tasks" :key="item.id">
<td class="p-5px w-20%">
{{ item.name }}
</td>
<td class="p-5px w-80%" colspan="3">
{{ item.description }}
<div v-if="item.signPicUrl && item.signPicUrl.length > 0">
<img class="w-90px h-40px" :src="item.signPicUrl" alt="" />
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="visible = false"> </el-button>
<el-button type="primary" v-print="printObj"> </el-button>
</div>
</template>
</el-dialog>
</template>
<style>
/* 修复打印只显示一页 */
@media print {
@page {
size: auto;
}
body,
html,
div {
height: auto !important;
}
}
</style>

View File

@ -51,8 +51,10 @@
>
<div class="ml-10px -mt-15px -mb-35px">
<ProcessInstanceTimeline
ref="nextAssigneesTimelineRef"
:activity-nodes="nextAssigneesActivityNode"
:show-status-icon="false"
:enable-approve-user-select="true"
@select-user-confirm="selectNextAssigneesConfirm"
/>
</div>
@ -571,6 +573,7 @@ const approveFormRef = ref<FormInstance>()
const signRef = ref()
const approveSignFormRef = ref()
const nextAssigneesActivityNode = ref<ProcessInstanceApi.ApprovalNodeInfo[]>([]) //
const nextAssigneesTimelineRef = ref() // 线
const approveReasonForm = reactive({
reason: '',
signPicUrl: '',
@ -579,7 +582,7 @@ const approveReasonForm = reactive({
const approveReasonRule = computed(() => {
return {
reason: [
{ required: reasonRequire.value, message: nodeTypeName + '意见不能为空', trigger: 'blur' }
{ required: reasonRequire.value, message: nodeTypeName.value + '意见不能为空', trigger: 'blur' }
],
signPicUrl: [{ required: true, message: '签名不能为空', trigger: 'change' }],
nextAssignees: [{ required: true, message: '审批人不能为空', trigger: 'blur' }]
@ -717,6 +720,10 @@ const closePopover = (type: string, formRef: FormInstance | undefined) => {
}
popOverVisible.value[type] = false
nextAssigneesActivityNode.value = []
// Timeline
if (nextAssigneesTimelineRef.value) {
nextAssigneesTimelineRef.value.batchSetCustomApproveUsers({})
}
}
/** 流程通过时,根据表单变量查询新的流程节点,判断下一个节点类型是否为自选审批人 */
@ -729,6 +736,7 @@ const initNextAssigneesFormField = async () => {
processVariablesStr: JSON.stringify(variables)
})
if (data && data.length > 0) {
const customApproveUsersData: Record<string, any[]> = {} // Timeline
data.forEach((node: any) => {
if (
//
@ -740,7 +748,18 @@ const initNextAssigneesFormField = async () => {
) {
nextAssigneesActivityNode.value.push(node)
}
// candidateUsers customApproveUsers
if (node.candidateUsers && node.candidateUsers.length > 0) {
customApproveUsersData[node.id] = node.candidateUsers
}
})
// candidateUsers Timeline
await nextTick() // tick Timeline
if (nextAssigneesTimelineRef.value && Object.keys(customApproveUsersData).length > 0) {
nextAssigneesTimelineRef.value.batchSetCustomApproveUsers(customApproveUsersData)
}
}
}
@ -803,6 +822,10 @@ const handleAudit = async (pass: boolean, formRef: FormInstance | undefined) =>
await TaskApi.approveTask(data)
popOverVisible.value.approve = false
nextAssigneesActivityNode.value = []
// Timeline
if (nextAssigneesTimelineRef.value) {
nextAssigneesTimelineRef.value.batchSetCustomApproveUsers({})
}
message.success('审批通过成功')
} else {
//

View File

@ -15,7 +15,7 @@
>
<img class="w-full h-full" :src="getApprovalNodeImg(activity.nodeType)" alt="" />
<div
v-if="showStatusIcon"
v-if="props.showStatusIcon"
class="position-absolute top-17px left-17px rounded-full flex items-center p-1px border-2 border-white border-solid"
:style="{ backgroundColor: getApprovalNodeColor(activity.status) }"
>
@ -55,13 +55,13 @@
class="flex flex-wrap gap2 items-center"
v-if="
isEmpty(activity.tasks) &&
isEmpty(activity.candidateUsers) &&
(CandidateStrategy.START_USER_SELECT === activity.candidateStrategy ||
CandidateStrategy.APPROVE_USER_SELECT === activity.candidateStrategy)
((CandidateStrategy.START_USER_SELECT === activity.candidateStrategy &&
isEmpty(activity.candidateUsers)) ||
(props.enableApproveUserSelect &&
CandidateStrategy.APPROVE_USER_SELECT === activity.candidateStrategy))
"
>
<!-- && activity.nodeType === NodeType.USER_TASK_NODE -->
<el-tooltip content="添加用户" placement="left">
<el-button
class="!px-6px"
@ -119,7 +119,7 @@
</template>
<!-- 信息任务 ICON -->
<div
v-if="showStatusIcon && onlyStatusIconShow.includes(task.status)"
v-if="props.showStatusIcon && onlyStatusIconShow.includes(task.status)"
class="position-absolute top-19px left-23px rounded-full flex items-center p-1px border-2 border-white border-solid"
:style="{ backgroundColor: statusIconMap2[task.status]?.color }"
>
@ -165,7 +165,7 @@
<!-- 信息任务 ICON -->
<div
v-if="showStatusIcon"
v-if="props.showStatusIcon"
class="position-absolute top-20px left-24px rounded-full flex items-center p-1px border-2 border-white border-solid"
:style="{ backgroundColor: statusIconMap2['-1']?.color }"
>
@ -198,13 +198,15 @@ import transactorSvg from '@/assets/svgs/bpm/transactor.svg'
import childProcessSvg from '@/assets/svgs/bpm/child-process.svg'
defineOptions({ name: 'BpmProcessInstanceTimeline' })
withDefaults(
const props = withDefaults(
defineProps<{
activityNodes: ProcessInstanceApi.ApprovalNodeInfo[] //
showStatusIcon?: boolean //
enableApproveUserSelect?: boolean //
}>(),
{
showStatusIcon: true // true
showStatusIcon: true, // true
enableApproveUserSelect: false // false
}
)
const { push } = useRouter() //
@ -341,4 +343,19 @@ const handleChildProcess = (activity: any) => {
}
})
}
/** 设置自定义审批人 */
const setCustomApproveUsers = (activityId: string, users: any[]) => {
customApproveUsers.value[activityId] = users || []
}
/** 批量设置多个节点的自定义审批人 */
const batchSetCustomApproveUsers = (data: Record<string, any[]>) => {
Object.keys(data).forEach((activityId) => {
customApproveUsers.value[activityId] = data[activityId] || []
})
}
//
defineExpose({ setCustomApproveUsers, batchSetCustomApproveUsers })
</script>

Some files were not shown because too many files have changed in this diff Show More