Merge pull request !2 from 吴瑞云/feature-init-lxy
pull/868/head^2
吴瑞云 2026-03-21 08:33:26 +00:00 committed by Gitee
commit 682239954e
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
171 changed files with 6815 additions and 2567 deletions

2
.env
View File

@ -34,4 +34,4 @@ VITE_APP_API_ENCRYPT_RESPONSE_KEY = 96103715984234343991809655248883
# VITE_APP_API_ENCRYPT_RESPONSE_KEY = MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAOH8IfIFxL/MR9XIg1UDv5z1fGXQI93E8wrU4iPFovL/sEt9uSgSkjyidC2O7N+m7EKtoN6b1u7cEwXSkwf3kfK0jdWLSQaNpX5YshqXCBzbDfugDaxuyYrNA4/tIMs7mzZAk0APuRXB35Dmupou7Yw7TFW/7QhQmGfzeEKULQvnAgMBAAECgYAw8LqlQGyQoPv5p3gRxEMOCfgL0JzD3XBJKztiRd35RDh40Nx1ejgjW4dPioFwGiVWd2W8cAGHLzALdcQT2KDJh+T/tsd4SPmI6uSBBK6Ff2DkO+kFFcuYvfclQQKqxma5CaZOSqhgenacmgTMFeg2eKlY3symV6JlFNu/IKU42QJBAOhxAK/Eq3e61aYQV2JSguhMR3b8NXJJRroRs/QHEanksJtl+M+2qhkC9nQVXBmBkndnkU/l2tYcHfSBlAyFySMCQQD445tgm/J2b6qMQmuUGQAYDN8FIkHjeKmha+l/fv0igWm8NDlBAem91lNDIPBUzHL1X1+pcts5bjmq99YdOnhtAkAg2J8dN3B3idpZDiQbC8fd5bGPmdI/pSUudAP27uzLEjr2qrE/QPPGdwm2m7IZFJtK7kK1hKio6u48t/bg0iL7AkEAuUUs94h+v702Fnym+jJ2CHEkXvz2US8UDs52nWrZYiM1o1y4tfSHm8H8bv8JCAa9GHyriEawfBraILOmllFdLQJAQSRZy4wmlaG48MhVXodB85X+VZ9krGXZ2TLhz7kz9iuToy53l9jTkESt6L5BfBDCVdIwcXLYgK+8KFdHN5W7HQ==
# 百度地图
VITE_BAIDU_MAP_KEY = 'efHIw2qmH8RzHPxK0z0rbCgzDVLup9LD'
VITE_BAIDU_MAP_KEY = 'Y2aJXiswwPxy6mwFs1z9c7U5gwX9WfUN'

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

638
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "yudao-ui-admin-vue3",
"version": "2025.09-snapshot",
"version": "2026.01-snapshot",
"description": "基于vue3、vite4、element-plus、typesScript",
"author": "xingyu",
"private": false,
@ -32,9 +32,9 @@
"@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/plugin-mention": "^1.0.0",
"@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",
@ -46,7 +46,7 @@
"dayjs": "^1.11.10",
"diagram-js": "^12.8.0",
"driver.js": "^1.3.1",
"echarts": "^5.5.0",
"echarts": "^5.6.0",
"echarts-wordcloud": "^2.1.0",
"element-plus": "2.11.1",
"fast-xml-parser": "^4.3.2",

File diff suppressed because it is too large Load Diff

View File

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

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

@ -13,6 +13,7 @@ export interface FileClientConfig {
accessSecret?: string
enablePathStyleAccess?: boolean
enablePublicAccess?: boolean
region?: string
domain: string
}

View File

@ -5,6 +5,7 @@ export interface DeviceVO {
id: number // 设备 ID主键自增
deviceName: string // 设备名称
productId: number // 产品编号
productName?: string // 产品名称(只有部分接口返回,例如 getDeviceLocationList
productKey: string // 产品标识
deviceType: number // 设备类型
nickname: string // 设备备注名称
@ -20,8 +21,6 @@ export interface DeviceVO {
mqttClientId: string // MQTT 客户端 ID
mqttUsername: string // MQTT 用户名
mqttPassword: string // MQTT 密码
authType: string // 认证类型
locationType: number // 定位类型
latitude?: number // 设备位置的纬度
longitude?: number // 设备位置的经度
areaId: number // 地区编码
@ -49,14 +48,6 @@ export interface IotDevicePropertyRespVO {
updateTime: Date // 更新时间
}
// TODO @芋艿:调整到 constants
// IoT 设备状态枚举
export enum DeviceStateEnum {
INACTIVE = 0, // 未激活
ONLINE = 1, // 在线
OFFLINE = 2 // 离线
}
// 设备认证参数 VO
export interface IotDeviceAuthInfoVO {
clientId: string // 客户端 ID
@ -123,6 +114,11 @@ export const DeviceApi = {
return await request.get({ url: `/iot/device/simple-list?`, params: { deviceType, productId } })
},
// 获取设备位置列表(用于地图展示)
getDeviceLocationList: async () => {
return await request.get<DeviceVO[]>({ url: `/iot/device/location-list` })
},
// 根据产品编号,获取设备的精简信息列表
getDeviceListByProductId: async (productId: number) => {
return await request.get({ url: `/iot/device/simple-list?`, params: { productId } })
@ -161,5 +157,28 @@ export const DeviceApi = {
// 发送设备消息
sendDeviceMessage: async (params: IotDeviceMessageSendReqVO) => {
return await request.post({ url: `/iot/device/message/send`, data: params })
},
// 绑定子设备到网关
bindDeviceGateway: async (data: { subIds: number[]; gatewayId: number }) => {
return await request.put({ url: `/iot/device/bind-gateway`, data })
},
// 解绑子设备与网关
unbindDeviceGateway: async (data: { subIds: number[]; gatewayId: number }) => {
return await request.put({ url: `/iot/device/unbind-gateway`, data })
},
// 获取网关的子设备列表
getSubDeviceList: async (gatewayId: number) => {
return await request.get<DeviceVO[]>({
url: `/iot/device/sub-device-list`,
params: { gatewayId }
})
},
// 获取未绑定网关的子设备分页
getUnboundSubDevicePage: async (params: any) => {
return await request.get({ url: `/iot/device/unbound-sub-device-page`, params })
}
}

View File

@ -0,0 +1,31 @@
import request from '@/config/axios'
/** Modbus 连接配置 VO */
export interface DeviceModbusConfigVO {
id?: number // 主键
deviceId: number // 设备编号
ip: string // Modbus 服务器 IP 地址
port: number // Modbus 服务器端口
slaveId: number // 从站地址
timeout: number // 连接超时时间,单位:毫秒
retryInterval: number // 重试间隔,单位:毫秒
mode: number // 模式
frameFormat: number // 帧格式
status: number // 状态
}
/** Modbus 连接配置 API */
export const DeviceModbusConfigApi = {
/** 获取设备的 Modbus 连接配置 */
getModbusConfig: async (deviceId: number) => {
return await request.get<DeviceModbusConfigVO>({
url: `/iot/device-modbus-config/get`,
params: { deviceId }
})
},
/** 保存 Modbus 连接配置 */
saveModbusConfig: async (data: DeviceModbusConfigVO) => {
return await request.post({ url: `/iot/device-modbus-config/save`, data })
}
}

View File

@ -0,0 +1,48 @@
import request from '@/config/axios'
/** Modbus 点位配置 VO */
export interface DeviceModbusPointVO {
id?: number // 主键
deviceId: number // 设备编号
thingModelId?: number // 物模型属性编号
identifier: string // 属性标识符
name: string // 属性名称
functionCode?: number // Modbus 功能码
registerAddress?: number // 寄存器起始地址
registerCount?: number // 寄存器数量
byteOrder?: string // 字节序
rawDataType?: string // 原始数据类型
scale: number // 缩放因子
pollInterval: number // 轮询间隔,单位:毫秒
status: number // 状态
}
/** Modbus 点位配置 API */
export const DeviceModbusPointApi = {
/** 获取设备的 Modbus 点位分页 */
getModbusPointPage: async (params: any) => {
return await request.get({ url: `/iot/device-modbus-point/page`, params })
},
/** 获取 Modbus 点位详情 */
getModbusPoint: async (id: number) => {
return await request.get<DeviceModbusPointVO>({
url: `/iot/device-modbus-point/get?id=${id}`
})
},
/** 创建 Modbus 点位配置 */
createModbusPoint: async (data: DeviceModbusPointVO) => {
return await request.post({ url: `/iot/device-modbus-point/create`, data })
},
/** 更新 Modbus 点位配置 */
updateModbusPoint: async (data: DeviceModbusPointVO) => {
return await request.put({ url: `/iot/device-modbus-point/update`, data })
},
/** 删除 Modbus 点位配置 */
deleteModbusPoint: async (id: number) => {
return await request.delete({ url: `/iot/device-modbus-point/delete?id=${id}` })
}
}

View File

@ -5,6 +5,8 @@ export interface ProductVO {
id: number // 产品编号
name: string // 产品名称
productKey: string // 产品标识
productSecret?: string // 产品密钥
registerEnabled?: boolean // 动态注册
protocolId: number // 协议编号
categoryId: number // 产品所属品类标识符
categoryName?: string // 产品所属品类名称
@ -13,9 +15,9 @@ export interface ProductVO {
description: string // 产品描述
status: number // 产品状态
deviceType: number // 设备类型
locationType: number // 设备类型
netType: number // 联网方式
codecType: string // 数据格式(编解码器类型)
protocolType: string // 协议类型
serializeType: string // 序列化类型
deviceCount: number // 设备数量
createTime: Date // 创建时间
}
@ -26,15 +28,23 @@ export enum DeviceTypeEnum {
GATEWAY_SUB = 1, // 网关子设备
GATEWAY = 2 // 网关设备
}
// IOT 产品定位类型枚举类 0: 手动定位, 1: IP 定位, 2: 定位模块定位
export enum LocationTypeEnum {
IP = 1, // IP 定位
MODULE = 2, // 设备定位
MANUAL = 3 // 手动定位
// IoT 协议类型枚举
export enum ProtocolTypeEnum {
TCP = 'tcp',
UDP = 'udp',
WEBSOCKET = 'websocket',
HTTP = 'http',
MQTT = 'mqtt',
EMQX = 'emqx',
COAP = 'coap',
MODBUS_TCP_CLIENT = 'modbus_tcp_client',
MODBUS_TCP_SERVER = 'modbus_tcp_server'
}
// IOT 数据格式(编解码器类型)枚举类
export enum CodecTypeEnum {
ALINK = 'Alink' // 阿里云 Alink 协议
// IoT 序列化类型枚举
export enum SerializeTypeEnum {
JSON = 'json',
BINARY = 'binary'
}
// IoT 产品 API
@ -75,8 +85,8 @@ export const ProductApi = {
},
// 查询产品(精简)列表
getSimpleProductList() {
return request.get({ url: '/iot/product/simple-list' })
getSimpleProductList(deviceType?: number) {
return request.get({ url: '/iot/product/simple-list', params: { deviceType } })
},
// 根据 ProductKey 获取产品信息

View File

@ -10,6 +10,8 @@ export interface DataSinkVO {
type?: number // 桥梁类型
config?:
| HttpConfig
| TcpConfig
| WebSocketConfig
| MqttConfig
| RocketMQConfig
| KafkaMQConfig
@ -30,6 +32,38 @@ export interface HttpConfig extends Config {
body: string
}
/** TCP 配置 */
export interface TcpConfig extends Config {
host: string
port: number
connectTimeoutMs: number
readTimeoutMs: number
ssl: boolean
sslCertPath: string
dataFormat: string
heartbeatIntervalMs: number
reconnectIntervalMs: number
maxReconnectAttempts: number
}
/** WebSocket 配置 */
export interface WebSocketConfig extends Config {
serverUrl: string
connectTimeoutMs: number
sendTimeoutMs: number
heartbeatIntervalMs: number
heartbeatMessage: string
subprotocols: string
customHeaders: string
verifySslCert: boolean
dataFormat: string
reconnectIntervalMs: number
maxReconnectAttempts: number
enableCompression: boolean
sendRetryCount: number
sendRetryIntervalMs: number
}
/** MQTT 配置 */
export interface MqttConfig extends Config {
url: string

View File

@ -16,18 +16,6 @@ export interface IotStatisticsSummaryRespVO {
productCategoryDeviceCounts: Record<string, number>
}
/** 时间戳-数值的键值对类型 */
interface TimeValueItem {
[key: string]: number
}
/** IoT 消息统计数据类型 */
export interface IotStatisticsDeviceMessageSummaryRespVO {
statType: number
upstreamCounts: TimeValueItem[]
downstreamCounts: TimeValueItem[]
}
/** 新的消息统计数据项 */
export interface IotStatisticsDeviceMessageSummaryByDateRespVO {
time: string
@ -41,6 +29,17 @@ export interface IotStatisticsDeviceMessageReqVO {
times?: string[]
}
/** 设备位置数据 VO */
export interface DeviceLocationRespVO {
id: number
deviceName: string
nickname?: string
productName?: string
state: number
longitude: number
latitude: number
}
// IoT 数据统计 API
export const StatisticsApi = {
// 查询全局的数据统计

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

@ -1,16 +1,14 @@
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 {

View File

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

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>()
@ -58,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
@ -65,7 +84,7 @@ const handleCreated = (editor: IDomEditor) => {
//
const editorConfig = computed((): IEditorConfig => {
return Object.assign(
return merge(
{
placeholder: '请输入内容...',
readOnly: props.readonly,
@ -90,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)
}
}
}
},
@ -243,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

@ -0,0 +1,137 @@
<!-- 省市区选择器 (Element Plus 版本 - Vue3) -->
<template>
<el-cascader
v-model="selectedValue"
class="w-full"
:options="areaTree"
:props="cascaderProps"
:disabled="disabled"
:placeholder="placeholder"
:clearable="clearable"
:show-all-levels="showAllLevels"
:separator="separator"
:loading="loading"
@change="handleChange"
/>
</template>
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue'
import { getAreaTree } from '@/api/system/area'
import { AreaLevelEnum } from '@/utils/constants'
defineOptions({ name: 'AreaSelect' })
interface AreaVO {
id: number
name: string
code: string
parentId?: number
sort?: number
status?: number
children?: AreaVO[]
}
interface Props {
modelValue?: number[] | string[]
level?: typeof AreaLevelEnum[keyof typeof AreaLevelEnum]
disabled?: boolean
placeholder?: string
clearable?: boolean
showAllLevels?: boolean
separator?: string
formCreateInject?: any
}
const props = withDefaults(defineProps<Props>(), {
modelValue: undefined,
level: AreaLevelEnum.DISTRICT,
disabled: false,
placeholder: '请选择省市区',
clearable: true,
showAllLevels: true,
separator: '/'
})
const emit = defineEmits<{
(e: 'update:modelValue', value: number[] | string[] | undefined): void
}>()
const cascaderProps = {
label: 'name',
value: 'id',
children: 'children',
checkStrictly: true, //
emitPath: true //
} // Element Plus Cascader props
const areaTree = ref<AreaVO[]>([]) //
const selectedValue = ref<number[] | undefined>() //
const loading = ref(false) //
/** 加载地区树形数据 */
async function loadAreaTree(): Promise<void> {
try {
loading.value = true
const data = await getAreaTree()
// level
areaTree.value = filterTreeByLevel(data || [], props.level)
} catch (error) {
console.warn('[AreaSelect] 加载地区数据失败:', error)
areaTree.value = []
} finally {
loading.value = false
}
}
/** 根据层级过滤树形数据 */
function filterTreeByLevel(tree: AreaVO[], maxLevel: number): AreaVO[] {
if (maxLevel <= 0) {
return []
}
return tree.map((node) => {
const newNode = { ...node }
// children
if (maxLevel === 1) {
delete newNode.children
} else if (node.children && node.children.length > 0) {
//
newNode.children = filterTreeByLevel(node.children, maxLevel - 1)
}
return newNode
})
}
/** 处理选中值变化 */
function handleChange(value: number[] | undefined): void {
if (value === undefined || value === null) {
emit('update:modelValue', undefined)
return
}
emit('update:modelValue', value)
}
/** 同步 modelValue 到内部选中值 */
function syncSelectedValue(): void {
const newValue = props.modelValue
if (newValue === undefined || newValue === null) {
selectedValue.value = undefined
return
}
//
if (Array.isArray(newValue)) {
selectedValue.value = newValue as number[]
} else {
selectedValue.value = [newValue as number]
}
}
/** 监听 modelValue 变化 */
watch(() => props.modelValue, syncSelectedValue, { immediate: true })
/** 组件挂载时加载数据 */
onMounted(async () => {
await loadAreaTree()
})
</script>

View File

@ -0,0 +1,196 @@
<!-- 部门选择器 - 树形结构显示 -->
<template>
<el-tree-select
v-model="selectedValue"
class="w-1/1"
:data="deptTree"
:props="treeProps"
:multiple="multiple"
:disabled="disabled"
:placeholder="placeholder || '请选择部门'"
:check-strictly="true"
:filterable="true"
:filter-node-method="filterNode"
:clearable="true"
:render-after-expand="false"
node-key="id"
@change="handleChange"
/>
</template>
<script lang="ts" setup>
import { handleTree } from '@/utils/tree'
import { getSimpleDeptList, type DeptVO } from '@/api/system/dept'
import { useUserStoreWithOut } from '@/store/modules/user'
defineOptions({ name: 'DeptSelect' })
//
interface Props {
modelValue?: number | string | number[] | string[]
multiple?: boolean
returnType?: 'id' | 'name'
defaultCurrentDept?: boolean
disabled?: boolean
placeholder?: string
formCreateInject?: any
}
const props = withDefaults(defineProps<Props>(), {
multiple: false,
returnType: 'id',
defaultCurrentDept: false,
disabled: false,
placeholder: ''
})
const emit = defineEmits<{
(e: 'update:modelValue', value: number | string | number[] | string[] | undefined): void
}>()
//
const treeProps = {
label: 'name',
value: 'id',
children: 'children'
}
//
const deptTree = ref<any[]>([])
// returnType='name'
const deptList = ref<DeptVO[]>([])
//
const selectedValue = ref<number | string | number[] | string[] | undefined>()
//
const loadDeptTree = async () => {
try {
const data = await getSimpleDeptList()
deptList.value = data
deptTree.value = handleTree(data)
} catch (error) {
console.warn('加载部门数据失败:', error)
deptTree.value = []
}
}
// ID
const getDeptNameById = (id: number): string | undefined => {
const dept = deptList.value.find((item) => item.id === id)
return dept?.name
}
// ID
const getDeptIdByName = (name: string): number | undefined => {
const dept = deptList.value.find((item) => item.name === name)
return dept?.id
}
//
const handleChange = (value: number | number[] | undefined) => {
if (value === undefined || value === null) {
emit('update:modelValue', props.multiple ? [] : undefined)
return
}
// returnType
if (props.returnType === 'name') {
if (props.multiple && Array.isArray(value)) {
const names = value.map((id) => getDeptNameById(id)).filter(Boolean) as string[]
emit('update:modelValue', names)
} else if (!props.multiple && typeof value === 'number') {
const name = getDeptNameById(value)
emit('update:modelValue', name)
}
} else {
emit('update:modelValue', value)
}
}
//
const filterNode = (value: string, data: any) => {
if (!value) return true
return data.name.includes(value)
}
// modelValue
watch(
() => props.modelValue,
(newValue) => {
if (newValue === undefined || newValue === null) {
selectedValue.value = props.multiple ? [] : undefined
return
}
// returnType 'name' ID
if (props.returnType === 'name') {
if (props.multiple && Array.isArray(newValue)) {
const ids = (newValue as string[])
.map((name) => getDeptIdByName(name))
.filter(Boolean) as number[]
selectedValue.value = ids
} else if (!props.multiple && typeof newValue === 'string') {
const id = getDeptIdByName(newValue)
selectedValue.value = id
}
} else {
selectedValue.value = newValue as number | number[]
}
},
{ immediate: true }
)
//
const hasValidPresetValue = (): boolean => {
const value = props.modelValue
if (value === undefined || value === null || value === '') {
return false
}
if (Array.isArray(value)) {
return value.length > 0
}
return true
}
//
const setDefaultValue = () => {
console.log('[DeptSelect] setDefaultValue called, defaultCurrentDept:', props.defaultCurrentDept)
// defaultCurrentDept true
if (!props.defaultCurrentDept) {
console.log('[DeptSelect] defaultCurrentDept is false, skip')
return
}
//
if (hasValidPresetValue()) {
console.log('[DeptSelect] has preset value, skip:', props.modelValue)
return
}
// ID
const userStore = useUserStoreWithOut()
const user = userStore.getUser
const deptId = user?.deptId
console.log('[DeptSelect] current user:', user, 'deptId:', deptId)
// deptId 0
if (!deptId || deptId === 0) {
console.log('[DeptSelect] deptId is invalid, skip')
return
}
//
const defaultValue = props.multiple ? [deptId] : deptId
console.log('[DeptSelect] setting default value:', defaultValue)
emit('update:modelValue', defaultValue)
}
//
onMounted(async () => {
await loadDeptTree()
//
setDefaultValue()
})
</script>

View File

@ -0,0 +1,86 @@
<!-- 网页 iframe 组件 (Element Plus 版本 - Vue3) -->
<template>
<div class="iframe-component">
<!-- iframe 预览 -->
<div v-if="showPreview" class="iframe-preview">
<iframe
:src="displayUrl"
:width="width"
:height="height"
:frameborder="frameborder"
:allowfullscreen="allowfullscreen"
:loading="loading"
:sandbox="sandbox || undefined"
class="iframe-content"
></iframe>
</div>
<!-- URL 或无效 URL 提示 -->
<div v-else class="iframe-placeholder">
<el-empty description="请在右侧属性面板配置 URL 地址" />
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { isUrl } from '@/utils/is'
defineOptions({ name: 'IframeComponent' })
interface Props {
modelValue?: string
url?: string
height?: string
width?: string
frameborder?: string
allowfullscreen?: boolean
loading?: 'eager' | 'lazy'
sandbox?: string
formCreateInject?: any
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
url: '',
height: '500px',
width: '100%',
frameborder: '0',
allowfullscreen: true,
loading: 'lazy',
sandbox: ''
})
const displayUrl = computed(() => props.url || props.modelValue || '') // URL使 url prop使 modelValue
const showPreview = computed(() => {
return displayUrl.value && isUrl(displayUrl.value)
}) //
</script>
<style scoped>
.iframe-component {
width: 100%;
}
.iframe-preview {
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
}
.iframe-content {
display: block;
border: none;
}
.iframe-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
border: 1px dashed #dcdfe6;
border-radius: 4px;
background-color: #fafafa;
}
</style>

View File

@ -2,6 +2,7 @@ import request from '@/config/axios'
import { isEmpty } from '@/utils/is'
import { ApiSelectProps } from '@/components/FormCreate/src/type'
import { jsonParse } from '@/utils'
import { useUserStoreWithOut } from '@/store/modules/user'
export const useApiSelect = (option: ApiSelectProps) => {
return defineComponent({
@ -56,13 +57,58 @@ export const useApiSelect = (option: ApiSelectProps) => {
remoteField: {
type: String,
default: 'label'
},
// 返回值类型用于部门选择器等id 返回 IDname 返回名称
returnType: {
type: String,
default: 'id'
},
// 是否默认选中当前用户(仅 UserSelect 使用)
defaultCurrentUser: {
type: Boolean,
default: false
}
},
setup(props) {
setup(props, { emit }) {
const attrs = useAttrs()
const options = ref<any[]>([]) // 下拉数据
const loading = ref(false) // 是否正在从远程获取数据
const queryParam = ref<any>() // 当前输入的值
// 检查是否有有效的预设值
const hasValidPresetValue = (): boolean => {
const value = attrs.modelValue
if (value === undefined || value === null || value === '') {
return false
}
if (Array.isArray(value)) {
return value.length > 0
}
return true
}
// 设置默认当前用户(仅当 defaultCurrentUser 为 true 且无预设值时)
const setDefaultCurrentUser = () => {
// 仅当组件名为 UserSelect 且 defaultCurrentUser 为 true 时处理
if (option.name !== 'UserSelect' || !props.defaultCurrentUser) {
return
}
// 检查是否已有预设值(预设值优先级高于默认当前用户)
if (hasValidPresetValue()) {
return
}
// 获取当前用户 ID
const userStore = useUserStoreWithOut()
const user = userStore.getUser
const currentUserId = user?.id
if (currentUserId) {
// 根据多选/单选模式设置默认值
const defaultValue = props.multiple ? [currentUserId] : currentUserId
emit('update:modelValue', defaultValue)
}
}
const getOptions = async () => {
options.value = []
// 接口选择器
@ -119,10 +165,21 @@ export const useApiSelect = (option: ApiSelectProps) => {
function parseOptions0(data: any[]) {
if (Array.isArray(data)) {
options.value = data.map((item: any) => ({
label: parseExpression(item, props.labelField),
value: parseExpression(item, props.valueField)
}))
options.value = data.map((item: any) => {
const label = parseExpression(item, props.labelField)
let value = parseExpression(item, props.valueField)
// 根据 returnType 决定返回值
// 如果设置了 returnType 为 'name',则返回 label 作为 value
if (props.returnType === 'name') {
value = label
}
return {
label: label,
value: value
}
})
return
}
console.warn(`接口[${props.url}] 返回结果不是一个数组`)
@ -172,6 +229,8 @@ export const useApiSelect = (option: ApiSelectProps) => {
onMounted(async () => {
await getOptions()
// 设置默认当前用户(在数据加载完成后)
setDefaultCurrentUser()
})
const buildSelect = () => {

View File

@ -4,6 +4,8 @@ import { useUploadImgsRule } from './useUploadImgsRule'
import { useDictSelectRule } from './useDictSelectRule'
import { useEditorRule } from './useEditorRule'
import { useSelectRule } from './useSelectRule'
import { useIframeRule } from './useIframeRule'
import { useAreaSelectRule } from './useAreaSelectRule'
export {
useUploadFileRule,
@ -11,5 +13,7 @@ export {
useUploadImgsRule,
useDictSelectRule,
useEditorRule,
useSelectRule
useSelectRule,
useIframeRule,
useAreaSelectRule
}

View File

@ -0,0 +1,75 @@
import { generateUUID } from '@/utils'
import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
import { AreaLevelEnum } from '@/utils/constants'
/**
*
*/
export const useAreaSelectRule = () => {
const label = '省市区选择器'
const name = 'AreaSelect'
return {
icon: 'icon-location',
label,
name,
rule() {
return {
type: name,
field: generateUUID(),
title: label,
info: '',
$required: false
}
},
props(_, { t }) {
return localeProps(t, name + '.props', [
makeRequiredRule(),
{
type: 'select',
field: 'level',
title: '选择层级',
value: AreaLevelEnum.DISTRICT,
options: [
{ label: '省', value: AreaLevelEnum.PROVINCE },
{ label: '省/市', value: AreaLevelEnum.CITY },
{ label: '省/市/区', value: AreaLevelEnum.DISTRICT }
],
info: '限制可选择的地区层级'
},
{
type: 'input',
field: 'placeholder',
title: '占位符',
value: '请选择省市区'
},
{
type: 'switch',
field: 'clearable',
title: '是否可清空',
value: true
},
{
type: 'switch',
field: 'showAllLevels',
title: '显示完整路径',
value: true,
info: '输入框中是否显示选中值的完整路径'
},
{
type: 'input',
field: 'separator',
title: '分隔符',
value: '/',
info: '选项分隔符'
},
{
type: 'switch',
field: 'disabled',
title: '是否禁用',
value: false
}
])
}
}
}

View File

@ -0,0 +1,74 @@
import { generateUUID } from '@/utils'
import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
/**
* iframe
*/
export const useIframeRule = () => {
const label = '网页 iframe'
const name = 'IframeComponent'
return {
icon: 'icon-link',
label,
name,
rule() {
return {
type: name,
field: generateUUID(),
title: label,
info: '',
$required: false
}
},
props(_, { t }) {
return localeProps(t, name + '.props', [
makeRequiredRule(),
{
type: 'input',
field: 'url',
title: 'URL 地址',
value: '',
info: '请输入完整的 HTTP 或 HTTPS 地址'
},
{
type: 'input',
field: 'height',
title: 'iframe 高度',
value: '500px',
info: '支持 px、%、vh 等单位'
},
{
type: 'input',
field: 'width',
title: 'iframe 宽度',
value: '100%',
info: '支持 px、%、vw 等单位'
},
{
type: 'select',
field: 'loading',
title: '加载方式',
value: 'lazy',
options: [
{ label: '懒加载', value: 'lazy' },
{ label: '立即加载', value: 'eager' }
]
},
{
type: 'switch',
field: 'allowfullscreen',
title: '允许全屏',
value: true
},
{
type: 'input',
field: 'sandbox',
title: 'sandbox 属性',
value: '',
info: '安全沙箱限制allow-scripts allow-same-origin'
}
])
}
}
}

View File

@ -19,13 +19,24 @@ export const useSelectRule = (option: SelectRuleOption) => {
name,
event: option.event,
rule() {
return {
// 构建基础规则
const baseRule: any = {
type: name,
field: generateUUID(),
title: label,
info: '',
$required: false
}
// 将自定义 props 的默认值添加到 rule 的 props 中
if (option.props && option.props.length > 0) {
baseRule.props = {}
option.props.forEach((prop: any) => {
if (prop.field && prop.value !== undefined) {
baseRule.props[prop.field] = prop.value
}
})
}
return baseRule
},
props(_, { t }) {
if (!option.props) {

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

@ -4,11 +4,14 @@ import {
useSelectRule,
useUploadFileRule,
useUploadImgRule,
useUploadImgsRule
useUploadImgsRule,
useIframeRule,
useAreaSelectRule
} from './config'
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,8 +37,10 @@ export const useFormCreateDesigner = async (designer: Ref) => {
// 移除自带的上传组件规则,使用 uploadFileRule、uploadImgRule、uploadImgsRule 替代
designer.value?.removeMenuItem('upload')
// 移除自带的富文本组件规则,使用 editorRule 替代
designer.value?.removeMenuItem('fc-editor')
const components = [editorRule, uploadFileRule, uploadImgRule, uploadImgsRule]
designer.value?.removeMenuItem('fcEditor')
const iframeRule = useIframeRule()
const areaSelectRule = useAreaSelectRule()
const components = [editorRule, uploadFileRule, uploadImgRule, uploadImgsRule, iframeRule, areaSelectRule]
components.forEach((component) => {
// 插入组件规则
designer.value?.addComponent(component)
@ -51,12 +56,38 @@ export const useFormCreateDesigner = async (designer: Ref) => {
const userSelectRule = useSelectRule({
name: 'UserSelect',
label: '用户选择器',
icon: 'icon-user-o'
icon: 'icon-user-o',
props: [
{
type: 'switch',
field: 'defaultCurrentUser',
title: '默认选中当前用户',
value: false
}
]
})
const deptSelectRule = useSelectRule({
name: 'DeptSelect',
label: '部门选择器',
icon: 'icon-address-card-o'
icon: 'icon-address-card-o',
props: [
{
type: 'select',
field: 'returnType',
title: '返回值类型',
value: 'id',
options: [
{ label: '部门编号', value: 'id' },
{ label: '部门名称', value: 'name' }
]
},
{
type: 'switch',
field: 'defaultCurrentDept',
title: '默认选中当前部门',
value: false
}
]
})
const dictSelectRule = useDictSelectRule()
const apiSelectRule0 = useSelectRule({
@ -93,9 +124,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

@ -0,0 +1,4 @@
import MapDialog from './src/MapDialog.vue'
export { loadBaiduMapSdk } from './src/utils'
export { MapDialog }

View File

@ -1,268 +0,0 @@
<!-- 地图组件基于百度地图GL实现 -->
<!-- TODO @super还存在两个没解决的小bug,一个是修改手动定位时一次加载 不知道为何定位点在地图左上角 调了半天没解决 第二个是检索地址确定定位的功能参照百度的文档没也搞好 回头再解决一下 -->
<template>
<div v-if="props.isWrite">
<el-form ref="form" label-width="120px">
<el-form-item label="定位位置:">
<el-select
class="w-full"
v-model="state.address"
clearable
filterable
remote
reserve-keyword
placeholder="可输入地址查询经纬度"
:remote-method="autoSearch"
@change="handleAddressSelect"
:loading="state.loading"
>
<el-option
v-for="item in state.mapAddrOptions"
:key="item.value"
:label="item.name"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="设备地图:">
<!-- TODO @super这里看看 unocss -->
<div id="bdMap" class="mapContainer"></div>
</el-form-item>
</el-form>
</div>
<div v-else>
<el-descriptions :column="2" border :labelStyle="{ 'font-weight': 'bold' }">
<el-descriptions-item label="设备位置:">{{ state.address }}</el-descriptions-item>
</el-descriptions>
<div id="bdMap" class="mapContainer"></div>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted } from 'vue'
import { propTypes } from '@/utils/propTypes'
// Window GL API
declare global {
interface Window {
BMapGL: any
initBaiduMap: () => void
}
}
const emits = defineEmits(['locateChange', 'update:center'])
const state = reactive({
lonLat: '', // ,
address: '',
loading: false,
latitude: '', //
longitude: '', //
map: null as any, //
mapAddrOptions: [] as any[],
mapMarker: null as any, //
geocoder: null as any,
autoComplete: null as any,
tips: [] //
})
const props = defineProps({
clickMap: propTypes.bool.def(false),
isWrite: propTypes.bool.def(false),
center: propTypes.string.def('')
})
/** 加载百度地图 */
const loadMap = () => {
state.address = ''
state.latitude = ''
state.longitude = ''
// API
const script = document.createElement('script')
script.src = `https://api.map.baidu.com/api?v=1.0&type=webgl&ak=${
import.meta.env.VITE_BAIDU_MAP_KEY
}&callback=initBaiduMap`
document.body.appendChild(script)
//
window.initBaiduMap = () => {
initMap()
initGeocoder()
initAutoComplete()
// TODO @super
if (props.clickMap) {
state.map.addEventListener('click', (e: any) => {
console.log(e)
const point = e.latlng
console.log(point)
state.lonLat = point.lng + ',' + point.lat
console.log(state.lonLat)
regeoCode(state.lonLat)
})
}
// TODO @super
if (props.center) {
regeoCode(props.center)
}
}
}
/** 初始化地图 */
const initMap = () => {
const mapId = 'bdMap'
state.map = new window.BMapGL.Map(mapId)
// TODO @super
state.map.centerAndZoom(new window.BMapGL.Point(116.404, 39.915), 11)
state.map.enableScrollWheelZoom()
state.map.disableDoubleClickZoom()
//
state.map.addControl(new window.BMapGL.NavigationControl())
state.map.addControl(new window.BMapGL.ScaleControl())
state.map.addControl(new window.BMapGL.ZoomControl())
}
/** 初始化地理编码器 */
const initGeocoder = () => {
state.geocoder = new window.BMapGL.Geocoder()
}
/** 初始化自动完成 */
const initAutoComplete = () => {
state.autoComplete = new window.BMapGL.Autocomplete({
input: 'searchInput',
location: state.map
})
}
/**
* 搜索地址
* @param queryValue 搜索关键词
*/
const autoSearch = (queryValue: string) => {
if (!queryValue) {
state.mapAddrOptions = []
return
}
state.loading = true
// 使
const localSearch = new window.BMapGL.LocalSearch(state.map, {
onSearchComplete: (results: any) => {
state.loading = false
const temp: any[] = []
if (results && results.getPoi) {
const pois = results.getPoi()
pois.forEach((p: any) => {
const point = p.point
if (point && point.lng && point.lat) {
temp.push({
name: p.title,
value: point.lng + ',' + point.lat
})
}
})
}
state.mapAddrOptions = temp
}
})
localSearch.search(queryValue)
}
/**
* 处理地址选择
* @param value 选中的地址值
*/
const handleAddressSelect = (value: string) => {
if (value) {
regeoCode(value)
}
}
/**
* 添加标记点
* @param lnglat 经纬度数组
*/
// TODO @super idea 绿
const setMarker = (lnglat: any) => {
if (!lnglat) return
//
if (state.mapMarker !== null) {
state.map.removeOverlay(state.mapMarker)
state.lonLat = ''
}
//
const point = new window.BMapGL.Point(lnglat[0], lnglat[1])
state.mapMarker = new window.BMapGL.Marker(point)
//
state.map.addOverlay(state.mapMarker)
state.map.centerAndZoom(point, 16)
}
/**
* 经纬度转化为地址添加标记点
* @param lonLat 经度,纬度字符串
*/
// TODO @super idea 绿
const regeoCode = (lonLat: string) => {
if (!lonLat) return
// TODO @super idea 绿
const lnglat = lonLat.split(',')
if (lnglat.length !== 2) return
state.longitude = lnglat[0]
state.latitude = lnglat[1]
//
emits('locateChange', lnglat)
emits('update:center', lonLat)
//
const point = new window.BMapGL.Point(lnglat[0], lnglat[1])
state.map.centerAndZoom(point, 16)
//
setMarker(lnglat)
getAddress(lnglat)
}
// TODO @superlnglat
/**
* 根据经纬度获取地址信息
*
* @param lnglat 经纬度数组
*/
const getAddress = (lnglat: any) => {
const point = new window.BMapGL.Point(lnglat[0], lnglat[1])
state.geocoder.getLocation(point, (result: any) => {
if (result && result.address) {
state.address = result.address
}
})
}
/** 显式暴露方法,使其可以被父组件访问 */
defineExpose({ regeoCode })
onMounted(() => {
loadMap()
})
</script>
<style scoped>
.mapContainer {
width: 100%;
height: 400px;
}
</style>

View File

@ -0,0 +1,259 @@
<!-- 地图选择弹窗组件基于百度地图 GL 实现 -->
<template>
<Dialog
title="百度地图"
v-model="dialogVisible"
@opened="handleDialogOpened"
@closed="handleDialogClosed"
>
<div class="w-full">
<!-- 第一行位置搜索 -->
<el-form label-width="80px">
<el-form-item label="定位位置">
<el-select
class="w-full"
v-model="state.address"
clearable
filterable
remote
reserve-keyword
placeholder="可输入地址查询经纬度"
:remote-method="autoSearch"
@change="handleAddressSelect"
:loading="state.loading"
>
<el-option
v-for="item in state.mapAddressOptions"
:key="item.value"
:label="item.name"
:value="item.value"
/>
</el-select>
</el-form-item>
<!-- 第二行坐标显示 -->
<el-form-item label="当前坐标">
<div class="flex items-center gap-4">
<span>经度: {{ state.longitude || '-' }}</span>
<span>纬度: {{ state.latitude || '-' }}</span>
</div>
</el-form-item>
</el-form>
<!-- 第三行地图 -->
<div
v-if="state.mapContainerReady"
ref="mapContainerRef"
class="w-full h-[400px] mt-[10px]"
></div>
<div v-else class="w-full h-[400px] mt-[10px] flex items-center justify-center">
<span class="text-gray-400">地图加载中...</span>
</div>
</div>
<template #footer>
<el-button @click="handleConfirm" type="primary"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { reactive, ref, nextTick } from 'vue'
import { loadBaiduMapSdk } from './utils'
const emits = defineEmits(['confirm'])
const dialogVisible = ref(false)
const mapContainerRef = ref<HTMLElement>()
const state = reactive({
lonLat: '', // ","
address: '', //
loading: false, //
latitude: '', //
longitude: '', //
map: null as any, //
mapAddressOptions: [] as any[], //
mapMarker: null as any, //
geocoder: null as any, //
mapContainerReady: false //
})
//
const initLongitude = ref<number | undefined>()
const initLatitude = ref<number | undefined>()
/** 打开弹窗 */
const open = (longitude?: number, latitude?: number) => {
initLongitude.value = longitude
initLatitude.value = latitude
state.longitude = longitude ? String(longitude) : ''
state.latitude = latitude ? String(latitude) : ''
state.address = ''
state.mapAddressOptions = []
dialogVisible.value = true
}
defineExpose({ open })
/** 弹窗打开动画完成后初始化地图 */
const handleDialogOpened = async () => {
//
state.mapContainerReady = true
// DOM
await nextTick()
// SDK
await loadBaiduMapSdk()
initMapInstance()
}
/** 弹窗关闭后清理地图 */
const handleDialogClosed = () => {
//
if (state.map) {
state.map.destroy?.()
state.map = null
}
state.mapMarker = null
state.geocoder = null
state.mapContainerReady = false
}
/** 初始化地图实例 */
const initMapInstance = () => {
if (!mapContainerRef.value) {
return
}
//
initMap()
initGeocoder()
//
state.map.addEventListener('click', (e: any) => {
const point = e.latlng
state.lonLat = point.lng + ',' + point.lat
regeoCode(state.lonLat)
})
//
if (initLongitude.value && initLatitude.value) {
const lonLat = `${initLongitude.value},${initLatitude.value}`
regeoCode(lonLat)
}
}
/** 初始化地图 */
const initMap = () => {
state.map = new window.BMapGL.Map(mapContainerRef.value)
state.map.centerAndZoom(new window.BMapGL.Point(116.404, 39.915), 11)
state.map.enableScrollWheelZoom()
state.map.disableDoubleClickZoom()
state.map.addControl(new window.BMapGL.NavigationControl())
state.map.addControl(new window.BMapGL.ScaleControl())
state.map.addControl(new window.BMapGL.ZoomControl())
}
/** 初始化地理编码器 */
const initGeocoder = () => {
state.geocoder = new window.BMapGL.Geocoder()
}
/** 搜索地址 */
const autoSearch = (queryValue: string) => {
if (!queryValue) {
state.mapAddressOptions = []
return
}
state.loading = true
// noinspection JSUnusedGlobalSymbols
const localSearch = new window.BMapGL.LocalSearch(state.map, {
onSearchComplete: (results: any) => {
state.loading = false
const temp: any[] = []
if (results && results._pois) {
results._pois.forEach((p: any) => {
const point = p.point
if (point && point.lng && point.lat) {
temp.push({
name: p.title,
value: point.lng + ',' + point.lat
})
}
})
}
state.mapAddressOptions = temp
}
})
localSearch.search(queryValue)
}
/** 处理地址选择 */
const handleAddressSelect = (value: string) => {
if (value) {
regeoCode(value)
}
}
/** 添加标记点 */
const setMarker = (lnglat: string[]) => {
if (!lnglat) {
return
}
if (state.mapMarker !== null) {
state.map.removeOverlay(state.mapMarker)
}
const point = new window.BMapGL.Point(lnglat[0], lnglat[1])
state.mapMarker = new window.BMapGL.Marker(point)
state.map.addOverlay(state.mapMarker)
state.map.centerAndZoom(point, 16)
}
/** 经纬度转地址、添加标记点 */
const regeoCode = (lonLat: string) => {
if (!lonLat) {
return
}
const lnglat = lonLat.split(',')
if (lnglat.length !== 2) {
return
}
state.longitude = lnglat[0]
state.latitude = lnglat[1]
const point = new window.BMapGL.Point(lnglat[0], lnglat[1])
state.map.centerAndZoom(point, 16)
setMarker(lnglat)
getAddress(lnglat)
}
/** 根据经纬度获取地址信息 */
const getAddress = (lnglat: string[]) => {
const point = new window.BMapGL.Point(lnglat[0], lnglat[1])
state.geocoder.getLocation(point, (result: any) => {
if (result && result.address) {
state.address = result.address
}
})
}
/** 确认选择 */
const handleConfirm = () => {
if (state.longitude && state.latitude) {
emits('confirm', {
longitude: state.longitude,
latitude: state.latitude,
address: state.address
})
}
dialogVisible.value = false
}
</script>

View File

@ -0,0 +1,62 @@
/**
* SDK
*/
// 扩展 Window 接口以包含百度地图 GL API
declare global {
interface Window {
BMapGL: any
}
}
// 全局回调名称
const CALLBACK_NAME = '__BAIDU_MAP_LOAD_CALLBACK__'
// SDK 加载状态
let loadPromise: Promise<void> | null = null
/**
* GL SDK
* @param timeout 10000
* @returns Promise<void>
*/
export const loadBaiduMapSdk = (timeout = 10000): Promise<void> => {
// 已加载完成
if (window.BMapGL) {
return Promise.resolve()
}
// 正在加载中,返回同一个 Promise
if (loadPromise) {
return loadPromise
}
loadPromise = new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
loadPromise = null
reject(new Error('百度地图 SDK 加载超时'))
}, timeout)
// 全局回调
;(window as any)[CALLBACK_NAME] = () => {
clearTimeout(timeoutId)
delete (window as any)[CALLBACK_NAME]
resolve()
}
// 创建 script 标签
const script = document.createElement('script')
script.src = `https://api.map.baidu.com/api?v=1.0&type=webgl&ak=${
import.meta.env.VITE_BAIDU_MAP_KEY
}&callback=${CALLBACK_NAME}`
script.onerror = () => {
clearTimeout(timeoutId)
loadPromise = null
delete (window as any)[CALLBACK_NAME]
reject(new Error('百度地图 SDK 加载失败'))
}
document.body.appendChild(script)
})
return loadPromise
}

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

@ -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,6 +1,10 @@
import * as FileApi from '@/api/infra/file'
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
@ -16,6 +20,13 @@ 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 生成文件名称
@ -26,8 +37,9 @@ export const useUpload = (directory?: string) => {
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. 记录文件信息到后端(异步)
@ -39,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)
@ -72,7 +84,7 @@ function createFile(vo: FileApi.FilePresignedUrlRespVO, file: UploadRawFile, fil
url: vo.url,
path: vo.path,
name: fileName,
type: file.type,
type: file.type || 'application/octet-stream',
size: file.size
}
FileApi.createFile(fileVo)

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

@ -85,7 +85,7 @@ service.interceptors.request.use(
}
}
// 是否 API 加密
if ((config!.headers || {}).isEncrypt) {
if ((config!.headers || {}).isEncrypt && !(config!.headers || {}).isEncrypted) {
try {
// 加密请求数据
if (config.data) {
@ -169,6 +169,9 @@ service.interceptors.response.use(
cb()
})
requestList = []
if ((config!.headers || {}).isEncrypt){
(config!.headers || {}).isEncrypted = true
}
return service(config)
} catch (e) {
// 为什么需要 catch 异常呢?刷新失败时,请求因为 Promise.reject 触发异常。

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

@ -1,6 +1,6 @@
// 引入unocss css
import '@/plugins/unocss'
/*import 'uno.css'*/
// 导入全局的svg图标
import '@/plugins/svgIcon'
@ -47,10 +47,14 @@ import { setupWangEditorPlugin } from '@/views/bpm/model/form/PrintTemplate'
import print from 'vue3-print-nb' // 打印插件
import * as echarts from 'echarts'
// 创建实例
const setupAll = async () => {
const app = createApp(App)
app.config.globalProperties.$echarts = echarts
await setupI18n(app)
setupStore(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

@ -68,6 +68,9 @@ import { UploadFile, UploadImg, UploadImgs } from '@/components/UploadFile'
import { useApiSelect } from '@/components/FormCreate'
import { Editor } from '@/components/Editor'
import DictSelect from '@/components/FormCreate/src/components/DictSelect.vue'
import DeptSelect from '@/components/FormCreate/src/components/DeptSelect.vue'
import IframeComponent from '@/components/FormCreate/src/components/IframeComponent.vue'
import AreaSelect from '@/components/FormCreate/src/components/AreaSelect.vue'
const UserSelect = useApiSelect({
name: 'UserSelect',
@ -75,12 +78,6 @@ const UserSelect = useApiSelect({
valueField: 'id',
url: '/system/user/simple-list'
})
const DeptSelect = useApiSelect({
name: 'DeptSelect',
labelField: 'name',
valueField: 'id',
url: '/system/dept/simple-list'
})
const ApiSelect = useApiSelect({
name: 'ApiSelect'
})
@ -119,6 +116,8 @@ const components = [
DeptSelect,
ApiSelect,
Editor,
IframeComponent,
AreaSelect,
ElCollapse,
ElCollapseItem,
ElCard

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

@ -463,3 +463,13 @@ export const BpmAutoApproveType = {
APPROVE_ALL: 1, // 仅审批一次,后续重复的审批节点均自动通过
APPROVE_SEQUENT: 2 // 仅针对连续审批的节点自动通过
}
// ========== SYSTEM - 地区模块 ==========
/**
*
*/
export const AreaLevelEnum = {
PROVINCE: 1, // 省
CITY: 2, // 市
DISTRICT: 3 // 区
}

View File

@ -233,7 +233,8 @@ export enum DICT_TYPE {
IOT_NET_TYPE = 'iot_net_type', // IOT 联网方式
IOT_PRODUCT_STATUS = 'iot_product_status', // IOT 产品状态
IOT_PRODUCT_DEVICE_TYPE = 'iot_product_device_type', // IOT 产品设备类型
IOT_CODEC_TYPE = 'iot_codec_type', // IOT 数据格式(编解码器类型)
IOT_PROTOCOL_TYPE = 'iot_protocol_type', // IOT 协议类型
IOT_SERIALIZE_TYPE = 'iot_serialize_type', // IOT 序列化类型
IOT_LOCATION_TYPE = 'iot_location_type', // IOT 定位类型
IOT_DEVICE_STATE = 'iot_device_state', // IOT 设备状态
IOT_THING_MODEL_TYPE = 'iot_thing_model_type', // IOT 产品功能类型
@ -247,5 +248,7 @@ export enum DICT_TYPE {
IOT_ALERT_RECEIVE_TYPE = 'iot_alert_receive_type', // IoT 告警接收类型
IOT_OTA_TASK_DEVICE_SCOPE = 'iot_ota_task_device_scope', // IoT OTA任务设备范围
IOT_OTA_TASK_STATUS = 'iot_ota_task_status', // IoT OTA 任务状态
IOT_OTA_TASK_RECORD_STATUS = 'iot_ota_task_record_status' // IoT OTA 记录状态
IOT_OTA_TASK_RECORD_STATUS = 'iot_ota_task_record_status', // IoT OTA 记录状态
IOT_MODBUS_MODE = 'iot_modbus_mode', // IoT Modbus 工作模式
IOT_MODBUS_FRAME_FORMAT = 'iot_modbus_frame_format' // IoT Modbus 帧格式
}

View File

@ -4,24 +4,30 @@
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) => {
@ -30,15 +36,15 @@ export const decodeFields = (fields: string[]) => {
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(formCreate.parseJson(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,
@ -51,7 +57,7 @@ export const setConfAndFields2 = (
}
// @ts-ignore
detailPreview.option = formCreate.parseJson(conf)
detailPreview.option = decodeConf(conf)
// @ts-ignore
detailPreview.rule = decodeFields(fields)

View File

@ -39,6 +39,7 @@
</el-col>
<Verify
ref="verify"
v-if="resetPasswordData.captchaEnable === 'true'"
:captchaType="captchaType"
:imgSize="{ width: '400px', height: '200px' }"
mode="pop"

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

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

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

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

@ -6,9 +6,6 @@
</template>
<div class="flex flex-col">
<el-checkbox v-model="modelData.allowCancelRunningProcess" label="允许撤销审批中的申请" />
<div class="ml-22px">
<el-text type="info"> 第一个审批节点通过后提交人仍可撤销申请 </el-text>
</div>
</div>
</el-form-item>
<el-form-item class="mb-20px">

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { IDomEditor } from '@wangeditor/editor'
import { Editor, Toolbar } from '@wangeditor-next/editor-for-vue'
import { IDomEditor } from '@wangeditor-next/editor'
import MentionModal from './MentionModal.vue'
const emit = defineEmits(['confirm'])
@ -43,7 +43,7 @@ const handleConfirm = () => {
// Editor
const editorRef = shallowRef<IDomEditor>()
const editorId = ref('wangeEditor-1')
const editorId = ref('wangEditor-1')
const toolbarConfig = {
excludeKeys: ['group-video'],
insertKeys: {
@ -86,7 +86,7 @@ onBeforeUnmount(() => {
/>
</div>
<!-- TODO @unocss 简化 style -->
<div style="border: 1px solid #ccc; margin: 10px">
<div style=" margin: 10px;border: 1px solid #ccc">
<Toolbar
style="border-bottom: 1px solid #ccc"
:editor="editorRef"
@ -106,11 +106,11 @@ onBeforeUnmount(() => {
@insert-mention="insertMention"
/>
</div>
<div style="margin-right: 10px; float: right">
<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/editor/dist/css/style.css"></style>
<style src="@wangeditor-next/editor/dist/css/style.css"></style>

View File

@ -1,6 +1,6 @@
import { Boot } from '@wangeditor/editor'
import { Boot } from '@wangeditor-next/editor'
import processRecordModule from './module'
import mentionModule from '@wangeditor/plugin-mention'
import mentionModule from '@wangeditor-next/plugin-mention'
// 注册:要在创建编辑器之前注册,且只能注册一次,不可重复注册
export const setupWangEditorPlugin = () => {

View File

@ -1,4 +1,4 @@
import { SlateElement } from '@wangeditor/editor'
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>`

View File

@ -1,4 +1,4 @@
import { IModuleConf } from '@wangeditor/editor'
import { IModuleConf } from '@wangeditor-next/editor'
import withProcessRecord from './plugin'
import renderElemConf from './render-elem'
import elemToHtmlConf from './elem-to-html'

View File

@ -1,4 +1,4 @@
import { IButtonMenu, IDomEditor } from '@wangeditor/editor'
import { IButtonMenu, IDomEditor } from '@wangeditor-next/editor'
class ProcessRecordMenu implements IButtonMenu {
readonly tag: string

View File

@ -1,16 +1,28 @@
import { DOMElement } from './utils/dom'
import { IDomEditor, SlateDescendant, SlateElement } from '@wangeditor/editor'
import { IDomEditor, SlateDescendant, SlateElement } from '@wangeditor-next/editor'
/**
* HTML
* @param domElem HTML DOM Element
* @param children
* @param editor editor
* @returns myResume
*/
function parseHtml(
_elem: DOMElement,
_domElem: DOMElement,
_children: SlateDescendant[],
_editor: IDomEditor
): SlateElement {
return {
// TODO @lesan这里有个红色告警可以去掉哇
// TS 语法
// 生成“流程记录”元素(按照此前约定的数据结构)
const processRecord = {
type: 'process-record',
children: [{ text: '' }]
children: [{ text: '' }], // void node 必须有 children ,其中有一个空字符串,重要!!!
}
return processRecord
}
const parseHtmlConf = {

View File

@ -1,4 +1,4 @@
import { DomEditor, IDomEditor } from '@wangeditor/editor'
import { DomEditor, IDomEditor } from '@wangeditor-next/editor'
function withProcessRecord<T extends IDomEditor>(editor: T) {
const { isInline, isVoid } = editor

View File

@ -1,5 +1,5 @@
import { h, VNode } from 'snabbdom'
import { DomEditor, IDomEditor, SlateElement } from '@wangeditor/editor'
import { DomEditor, IDomEditor, SlateElement } from '@wangeditor-next/editor'
function renderProcessRecord(
elem: SlateElement,

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

@ -30,7 +30,11 @@ const open = async (id: string) => {
defineExpose({ open })
const parseFormFields = () => {
const formFieldsObj = decodeFields(printData.value.processInstance.processDefinition.formFields)
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) {

View File

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

View File

@ -275,21 +275,26 @@ const resetQuery = () => {
/** 发起流程操作 **/
const handleCreate = async (row?: ProcessInstanceVO) => {
//
if (row?.id) {
const processDefinitionDetail = await DefinitionApi.getProcessDefinition(
row.processDefinitionId
)
//
if (processDefinitionDetail.formType === 20) {
message.error('重新发起流程失败,原因:该流程使用业务表单,不支持重新发起')
return
await router.push({
path: processDefinitionDetail.formCustomCreatePath,
query: {
id: row.businessKey
}
})
} else if (processDefinitionDetail.formType === 10) {
//
await router.push({
name: 'BpmProcessInstanceCreate',
query: { processInstanceId: row.id }
})
}
}
//
await router.push({
name: 'BpmProcessInstanceCreate',
query: { processInstanceId: row?.id }
})
}
/** 查看详情 */

View File

@ -39,8 +39,8 @@
<el-select v-model="formData.event" placeholder="请选择事件">
<el-option
v-for="event in formData.type == 'execution'
? ['start', 'end']
: ['create', 'assignment', 'complete', 'delete', 'update', 'timeout']"
? ['开始', '结束']
: ['创建', '指派', '完成', '删除', '更新', '超时']"
:label="event"
:value="event"
:key="event"

View File

@ -52,13 +52,13 @@
<el-form-item label="" prop="status" :style="{ position: 'absolute', right: '130px' }">
<el-select
v-model="queryParams.status"
placeholder="请选择流程状态"
placeholder="请选择审批状态"
clearable
class="!w-155px"
@change="handleQuery"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS)"
v-for="dict in getIntDictOptions(DICT_TYPE.BPM_TASK_STATUS)"
:key="dict.value"
:label="dict.label"
:value="dict.value"

View File

@ -18,7 +18,7 @@
>
转化为客户
</el-button>
<el-button v-else disabled type="success">已转化客户</el-button>
<el-button v-if="clue.transformStatus" disabled type="success"></el-button>
</ClueDetailsHeader>
<el-col>
<el-tabs>

View File

@ -132,7 +132,7 @@ const submitFormSuccess = (response: any) => {
/** 上传错误提示 */
const submitFormError = (): void => {
message.error('上传失败,请您重新上传!')
formLoading.value = false
resetForm()
}
/** 重置表单 */

View File

@ -46,7 +46,11 @@
<CustomerDetailsInfo :customer="customer" />
</el-tab-pane>
<el-tab-pane label="联系人" lazy>
<ContactList :biz-id="customer.id!" :biz-type="BizTypeEnum.CRM_CUSTOMER" />
<ContactList
:biz-id="customer.id!"
:customer-id="customer.id!"
:biz-type="BizTypeEnum.CRM_CUSTOMER"
/>
</el-tab-pane>
<el-tab-pane label="团队成员">
<PermissionList
@ -58,7 +62,11 @@
/>
</el-tab-pane>
<el-tab-pane label="商机" lazy>
<BusinessList :biz-id="customer.id!" :biz-type="BizTypeEnum.CRM_CUSTOMER" />
<BusinessList
:biz-id="customer.id!"
:customer-id="customer.id!"
:biz-type="BizTypeEnum.CRM_CUSTOMER"
/>
</el-tab-pane>
<el-tab-pane label="合同" lazy>
<ContractList :biz-id="customer.id!" :biz-type="BizTypeEnum.CRM_CUSTOMER" />

View File

@ -33,16 +33,16 @@
class="!w-240px"
/>
</el-form-item>
<el-form-item label="供应商" prop="supplierId">
<el-form-item label="客户" prop="customerId">
<el-select
v-model="queryParams.supplierId"
v-model="queryParams.customerId"
clearable
filterable
placeholder="请选择供供应商"
placeholder="请选择供客户"
class="!w-240px"
>
<el-option
v-for="item in supplierList"
v-for="item in customerList"
:key="item.id"
:label="item.name"
:value="item.id"
@ -272,8 +272,8 @@ import FinanceReceiptForm from './FinanceReceiptForm.vue'
import { UserVO } from '@/api/system/user'
import * as UserApi from '@/api/system/user'
import { erpPriceTableColumnFormatter } from '@/utils'
import { SupplierApi, SupplierVO } from '@/api/erp/purchase/supplier'
import { AccountApi, AccountVO } from '@/api/erp/finance/account'
import { CustomerApi, CustomerVO } from '@/api/erp/sale/customer'
/** ERP 收款单列表 */
defineOptions({ name: 'ErpPurchaseOrder' })
@ -289,7 +289,7 @@ const queryParams = reactive({
pageSize: 10,
no: undefined,
receiptTime: [],
supplierId: undefined,
customerId: undefined,
creator: undefined,
financeUserId: undefined,
accountId: undefined,
@ -299,7 +299,7 @@ const queryParams = reactive({
})
const queryFormRef = ref() //
const exportLoading = ref(false) //
const supplierList = ref<SupplierVO[]>([]) //
const customerList = ref<CustomerVO[]>([]) //
const userList = ref<UserVO[]>([]) //
const accountList = ref<AccountVO[]>([]) //
@ -384,8 +384,8 @@ const handleSelectionChange = (rows: FinanceReceiptVO[]) => {
/** 初始化 **/
onMounted(async () => {
await getList()
//
supplierList.value = await SupplierApi.getSupplierSimpleList()
//
customerList.value = await CustomerApi.getCustomerSimpleList()
userList.value = await UserApi.getSimpleUserList()
accountList.value = await AccountApi.getAccountSimpleList()
})

View File

@ -17,13 +17,7 @@
:rules="formRules.warehouseId"
class="mb-0px!"
>
<el-select
v-model="row.warehouseId"
clearable
filterable
placeholder="请选择仓库"
@change="onChangeWarehouse($event, row)"
>
<el-select v-model="row.warehouseId" clearable filterable placeholder="请选择仓库">
<el-option
v-for="item in warehouseList"
:key="item.id"

View File

@ -132,7 +132,7 @@
</el-table>
</el-form>
<el-row justify="center" class="mt-3" v-if="!disabled">
<el-button @click="handleAdd" round>+ 添加采购产品</el-button>
<el-button @click="handleAdd" round>+ 添加销售产品</el-button>
</el-row>
</template>
<script setup lang="ts">

View File

@ -136,7 +136,7 @@ const makeTemplate = () => {
/** 复制 **/
const copy = async (text: string) => {
const textToCopy = JSON.stringify(text, null, 2)
const { copy, copied, isSupported } = useClipboard({ source: textToCopy })
const { copy, copied, isSupported } = useClipboard({ legacy: true, source: textToCopy })
if (!isSupported) {
message.error(t('common.copyError'))
} else {

View File

@ -180,7 +180,7 @@ const handleFiles = (datas: CodegenApi.CodegenPreviewVO[]) => {
/** 复制 **/
const copy = async (text: string) => {
const { copy, copied, isSupported } = useClipboard({ source: text })
const { copy, copied, isSupported } = useClipboard({ legacy: true, source: text })
if (!isSupported) {
message.error(t('common.copyError'))
return

View File

@ -11,6 +11,7 @@
:on-change="handleFileChange"
:on-error="submitFormError"
:on-exceed="handleExceed"
:on-progress="handleProgress"
:on-success="submitFormSuccess"
:http-request="httpRequest"
accept=".jpg, .png, .gif"
@ -32,6 +33,7 @@
</template>
<script lang="ts" setup>
import { useUpload } from '@/components/UploadFile/src/useUpload'
import { UploadFile, UploadProgressEvent } from 'element-plus/es/components/upload/src/upload'
defineOptions({ name: 'InfraFileForm' })
@ -54,16 +56,22 @@ const open = async () => {
defineExpose({ open }) // open
/** 处理上传的文件发生变化 */
const handleFileChange = (file) => {
const handleFileChange = (file: UploadFile) => {
data.value.path = file.name
}
/** 处理文件上传进度显示 */
const handleProgress = (upEvt: UploadProgressEvent, file: UploadFile) => {
file.percentage = upEvt.percent
}
/** 提交表单 */
const submitFileForm = () => {
if (fileList.value.length == 0) {
message.error('请上传文件')
return
}
formLoading.value = true
unref(uploadRef)?.submit()
}

View File

@ -136,6 +136,7 @@ import { fileSizeFormatter } from '@/utils'
import { dateFormatter } from '@/utils/formatTime'
import * as FileApi from '@/api/infra/file'
import FileForm from './FileForm.vue'
import { useClipboard } from '@vueuse/core'
defineOptions({ name: 'InfraFile' })
@ -186,10 +187,16 @@ const openForm = () => {
}
/** 复制到剪贴板方法 */
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
message.success('复制成功')
})
const copyToClipboard = async (text: string) => {
const { copy, copied, isSupported } = useClipboard({ legacy: true, source: text })
if (!isSupported) {
message.error(t('common.copyError'))
return
}
await copy()
if (unref(copied)) {
message.success(t('common.copySuccess'))
}
}
/** 删除按钮操作 */

View File

@ -103,6 +103,10 @@
<el-radio key="false" :value="false">私有</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="formData.storage === 20" label="区域">
<!-- 选填无需参数校验 -->
<el-input v-model="formData.config.region" placeholder="请填写区域,一般仅 AWS 需要填写" />
</el-form-item>
<!-- 通用 -->
<el-form-item v-if="formData.storage === 20" label="自定义域名">
<!-- 无需参数校验所以去掉 prop -->

View File

@ -1,6 +1,7 @@
<template>
<doc-alert title="Redis 缓存" url="https://doc.iocoder.cn/redis-cache/" />
<doc-alert title="本地缓存" url="https://doc.iocoder.cn/local-cache/" />
<el-scrollbar height="calc(100vh - 88px - 40px - 50px)">
<el-row>
<!-- 基本信息 -->

View File

@ -30,20 +30,6 @@
:disabled="formType === 'update'"
/>
</el-form-item>
<el-form-item
v-if="formData.deviceType === DeviceTypeEnum.GATEWAY_SUB"
label="网关设备"
prop="gatewayId"
>
<el-select v-model="formData.gatewayId" placeholder="子设备可选择父设备" clearable>
<el-option
v-for="gateway in gatewayDevices"
:key="gateway.id"
:label="gateway.nickname || gateway.deviceName"
:value="gateway.id"
/>
</el-select>
</el-form-item>
<el-collapse>
<el-collapse-item title="更多配置">
@ -66,44 +52,17 @@
<el-form-item label="设备序列号" prop="serialNumber">
<el-input v-model="formData.serialNumber" placeholder="请输入设备序列号" />
</el-form-item>
<el-form-item label="定位类型" prop="locationType">
<el-radio-group v-model="formData.locationType">
<el-radio
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_LOCATION_TYPE)"
:key="dict.value"
:label="dict.value"
>
{{ dict.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<!-- LocationTypeEnum.MANUAL手动定位 -->
<template v-if="LocationTypeEnum.MANUAL === formData.locationType">
<el-form-item label="设备经度" prop="longitude" type="number">
<el-input
v-model="formData.longitude"
placeholder="请输入设备经度"
@blur="updateLocationFromCoordinates"
/>
</el-form-item>
<el-form-item label="设备维度" prop="latitude" type="number">
<el-input
v-model="formData.latitude"
placeholder="请输入设备维度"
@blur="updateLocationFromCoordinates"
/>
</el-form-item>
<div class="pl-0 h-[400px] w-full ml-[-18px]" v-if="showMap">
<Map
:isWrite="true"
:clickMap="true"
:center="formData.location"
@locate-change="handleLocationChange"
ref="mapRef"
class="h-full w-full"
/>
<el-form-item label="设备位置" prop="longitude">
<div class="flex items-center gap-2 w-full">
<el-input v-model="formData.longitude" placeholder="经度" class="flex-1">
<template #prepend>经度</template>
</el-input>
<el-input v-model="formData.latitude" placeholder="纬度" class="flex-1">
<template #prepend>纬度</template>
</el-input>
<el-button type="primary" @click="openMapDialog"></el-button>
</div>
</template>
</el-form-item>
</el-collapse-item>
</el-collapse>
</el-form>
@ -112,14 +71,15 @@
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
<!-- 地图选择弹窗 -->
<MapDialog ref="mapDialogRef" @confirm="handleMapConfirm" />
</template>
<script setup lang="ts">
import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
import { DeviceGroupApi } from '@/api/iot/device/group'
import { DeviceTypeEnum, LocationTypeEnum, ProductApi, ProductVO } from '@/api/iot/product/product'
import { DeviceTypeEnum, ProductApi, ProductVO } from '@/api/iot/product/product'
import { UploadImg } from '@/components/UploadFile'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import Map from '@/components/Map/index.vue'
import { MapDialog } from '@/components/Map'
import { ref } from 'vue'
/** IoT 设备表单 */
@ -132,8 +92,7 @@ const dialogVisible = ref(false) // 弹窗的是否展示
const dialogTitle = ref('') //
const formLoading = ref(false) // 12
const formType = ref('') // create - update -
const showMap = ref(false) //
const mapRef = ref(null)
const mapDialogRef = ref() // Ref
const formData = ref({
id: undefined,
@ -141,24 +100,26 @@ const formData = ref({
deviceName: undefined,
nickname: undefined,
picUrl: undefined,
gatewayId: undefined,
deviceType: undefined as number | undefined,
serialNumber: undefined,
locationType: undefined as number | undefined,
longitude: undefined,
latitude: undefined,
location: '', // : ","
longitude: undefined as number | string | undefined,
latitude: undefined as number | string | undefined,
groupIds: [] as number[]
})
/** 监听经纬度变化更新location */
watch([() => formData.value.longitude, () => formData.value.latitude], ([newLong, newLat]) => {
if (newLong && newLat) {
formData.value.location = `${newLong},${newLat}`
//
showMap.value = true
}
})
/** 打开地图选择弹窗 */
const openMapDialog = () => {
mapDialogRef.value?.open(
formData.value.longitude ? Number(formData.value.longitude) : undefined,
formData.value.latitude ? Number(formData.value.latitude) : undefined
)
}
/** 处理地图选择确认 */
const handleMapConfirm = (data: { longitude: string; latitude: string; address: string }) => {
formData.value.longitude = data.longitude
formData.value.latitude = data.latitude
}
const formRules = reactive({
productId: [{ required: true, message: '产品不能为空', trigger: 'blur' }],
@ -196,11 +157,56 @@ const formRules = reactive({
message: '序列号只能包含字母、数字、中划线和下划线',
trigger: 'blur'
}
],
longitude: [
{
validator: (_rule: any, value: any, callback: any) => {
if (value !== undefined && value !== null && value !== '') {
const num = Number(value)
if (isNaN(num)) {
callback(new Error('经度必须是有效数字'))
return
}
if (num < -180 || num > 180) {
callback(new Error('经度范围为 -180 到 180'))
return
}
if (!formData.value.latitude && formData.value.latitude !== 0) {
callback(new Error('请同时填写纬度'))
return
}
}
callback()
},
trigger: 'blur'
}
],
latitude: [
{
validator: (_rule: any, value: any, callback: any) => {
if (value !== undefined && value !== null && value !== '') {
const num = Number(value)
if (isNaN(num)) {
callback(new Error('纬度必须是有效数字'))
return
}
if (num < -90 || num > 90) {
callback(new Error('纬度范围为 -90 到 90'))
return
}
if (!formData.value.longitude && formData.value.longitude !== 0) {
callback(new Error('请同时填写经度'))
return
}
}
callback()
},
trigger: 'blur'
}
]
})
const formRef = ref() // Ref
const products = ref<ProductVO[]>([]) //
const gatewayDevices = ref<DeviceVO[]>([]) //
const deviceGroups = ref<any[]>([])
/** 打开弹窗 */
@ -210,28 +216,16 @@ const open = async (type: string, id?: number) => {
formType.value = type
resetForm()
//
showMap.value = false
//
if (id) {
formLoading.value = true
try {
formData.value = await DeviceApi.getDevice(id)
// location
if (formData.value.longitude && formData.value.latitude) {
formData.value.location = `${formData.value.longitude},${formData.value.latitude}`
}
} finally {
formLoading.value = false
}
}
//
showMap.value = true
//
gatewayDevices.value = await DeviceApi.getSimpleDeviceList(DeviceTypeEnum.GATEWAY)
//
products.value = await ProductApi.getSimpleProductList()
//
@ -248,16 +242,6 @@ const submitForm = async () => {
formLoading.value = true
try {
const data = formData.value as unknown as DeviceVO
//
if (data.locationType !== LocationTypeEnum.MANUAL) {
data.longitude = undefined
data.latitude = undefined
}
// TODO @address areaId
// 1. longitude + latitude + areaId + addressaddress
// 2. IP IotDeviceMessage buildStateUpdateOnline ip areaId https://lbsyun.baidu.com/faq/api?title=webapi/ip-api-base location ok
// 3. haohao
if (formType.value === 'create') {
await DeviceApi.createDevice(data)
message.success(t('common.createSuccess'))
@ -281,19 +265,13 @@ const resetForm = () => {
deviceName: undefined,
nickname: undefined,
picUrl: undefined,
gatewayId: undefined,
deviceType: undefined,
serialNumber: undefined,
locationType: undefined,
longitude: undefined,
latitude: undefined,
// TODO @location formData
location: '',
groupIds: []
}
formRef.value?.resetFields()
//
showMap.value = false
}
/** 产品选择变化 */
@ -304,22 +282,5 @@ const handleProductChange = (productId: number) => {
}
const product = products.value?.find((item) => item.id === productId)
formData.value.deviceType = product?.deviceType
formData.value.locationType = product?.locationType
}
/** 处理位置变化 */
const handleLocationChange = (lnglat) => {
formData.value.longitude = lnglat[0]
formData.value.latitude = lnglat[1]
}
/** 根据经纬度更新地图位置 */
const updateLocationFromCoordinates = () => {
//
if (formData.value.longitude && formData.value.latitude) {
// location
formData.value.location = `${formData.value.longitude},${formData.value.latitude}`
mapRef.value.regeoCode(formData.value.location)
}
}
</script>

View File

@ -115,7 +115,7 @@ const submitFormSuccess = (response: any) => {
/** 上传错误提示 */
const submitFormError = (): void => {
message.error('上传失败,请您重新上传!')
formLoading.value = false
resetForm()
}
/** 重置表单 */

View File

@ -39,8 +39,10 @@
import DeviceForm from '@/views/iot/device/device/DeviceForm.vue'
import { ProductVO } from '@/api/iot/product/product'
import { DeviceVO } from '@/api/iot/device/device'
import { useClipboard } from '@vueuse/core'
const message = useMessage()
const { t } = useI18n() //
const router = useRouter()
const { product, device } = defineProps<{ product: ProductVO; device: DeviceVO }>()
@ -54,11 +56,14 @@ const openForm = (type: string, id?: number) => {
/** 复制到剪贴板方法 */
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
message.success('复制成功')
} catch (error) {
message.error('复制失败')
const { copy, copied, isSupported } = useClipboard({ legacy: true, source: text })
if (!isSupported) {
message.error(t('common.copyError'))
return
}
await copy()
if (unref(copied)) {
message.success(t('common.copySuccess'))
}
}

View File

@ -2,83 +2,45 @@
<template>
<div>
<ContentWrap>
<el-row :gutter="16">
<!-- 左侧设备信息 -->
<el-col :span="12">
<el-card class="h-full">
<template #header>
<div class="flex items-center">
<Icon icon="ep:info-filled" class="mr-2 text-primary" />
<span>设备信息</span>
</div>
<el-descriptions :column="3" border>
<el-descriptions-item label="产品名称">{{ product.name }}</el-descriptions-item>
<el-descriptions-item label="ProductKey">{{ product.productKey }}</el-descriptions-item>
<el-descriptions-item label="设备类型">
<dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="product.deviceType" />
</el-descriptions-item>
<el-descriptions-item label="DeviceName">{{ device.deviceName }}</el-descriptions-item>
<el-descriptions-item label="备注名称">{{ device.nickname }}</el-descriptions-item>
<el-descriptions-item label="当前状态">
<dict-tag :type="DICT_TYPE.IOT_DEVICE_STATE" :value="device.state" />
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatDate(device.createTime) }}
</el-descriptions-item>
<el-descriptions-item label="激活时间">
{{ formatDate(device.activeTime) }}
</el-descriptions-item>
<el-descriptions-item label="最后上线时间">
{{ formatDate(device.onlineTime) }}
</el-descriptions-item>
<el-descriptions-item label="最后离线时间">
{{ formatDate(device.offlineTime) }}
</el-descriptions-item>
<el-descriptions-item label="设备位置">
<template v-if="hasLocation">
<span class="mr-2">{{ device.longitude }}, {{ device.latitude }}</span>
<el-button type="primary" link @click="openMapDialog">
<Icon icon="ep:location" class="mr-1" />
查看地图
</el-button>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="产品名称">{{ product.name }}</el-descriptions-item>
<el-descriptions-item label="ProductKey">
{{ product.productKey }}
</el-descriptions-item>
<el-descriptions-item label="设备类型">
<dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="product.deviceType" />
</el-descriptions-item>
<el-descriptions-item label="定位类型">
<dict-tag :type="DICT_TYPE.IOT_LOCATION_TYPE" :value="device.locationType" />
</el-descriptions-item>
<el-descriptions-item label="DeviceName">
{{ device.deviceName }}
</el-descriptions-item>
<el-descriptions-item label="备注名称">{{ device.nickname }}</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatDate(device.createTime) }}
</el-descriptions-item>
<el-descriptions-item label="当前状态">
<dict-tag :type="DICT_TYPE.IOT_DEVICE_STATE" :value="device.state" />
</el-descriptions-item>
<el-descriptions-item label="激活时间">
{{ formatDate(device.activeTime) }}
</el-descriptions-item>
<el-descriptions-item label="最后上线时间">
{{ formatDate(device.onlineTime) }}
</el-descriptions-item>
<el-descriptions-item label="最后离线时间">
{{ formatDate(device.offlineTime) }}
</el-descriptions-item>
<el-descriptions-item label="认证信息">
<el-button type="primary" @click="handleAuthInfoDialogOpen" plain size="small"
>查看</el-button
>
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<!-- 右侧地图 -->
<el-col :span="12">
<el-card class="h-full">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon icon="ep:location" class="mr-2 text-primary" />
<span>设备位置</span>
</div>
<div class="text-[14px] text-[var(--el-text-color-secondary)]">
最后上线时间
{{ device.onlineTime ? formatDate(device.onlineTime) : '--' }}
</div>
</div>
</template>
<div class="h-[400px] w-full">
<Map v-if="showMap" :center="getLocationString()" class="h-full w-full" />
<div
v-else
class="flex items-center justify-center h-full w-full bg-[var(--el-fill-color-light)] text-[var(--el-text-color-secondary)]"
>
<Icon icon="ep:warning" class="mr-2 text-warning" />
<span>暂无位置信息</span>
</div>
</div>
</el-card>
</el-col>
</el-row>
<span v-else class="text-[var(--el-text-color-secondary)]">暂无位置信息</span>
</el-descriptions-item>
<el-descriptions-item label="认证信息">
<el-button type="primary" @click="handleAuthInfoDialogOpen" plain size="small">
查看
</el-button>
</el-descriptions-item>
</el-descriptions>
</ContentWrap>
<!-- 认证信息弹框 -->
@ -128,6 +90,9 @@
<el-button @click="handleAuthInfoDialogClose"></el-button>
</template>
</Dialog>
<!-- 地图弹窗 -->
<MapDialog ref="mapDialogRef" />
</div>
<!-- TODO 待开发设备标签 -->
@ -138,10 +103,12 @@ import { ProductVO } from '@/api/iot/product/product'
import { formatDate } from '@/utils/formatTime'
import { DeviceVO } from '@/api/iot/device/device'
import { DeviceApi, IotDeviceAuthInfoVO } from '@/api/iot/device/device'
import Map from '@/components/Map/index.vue'
import { MapDialog } from '@/components/Map'
import { ref, computed } from 'vue'
import { useClipboard } from '@vueuse/core'
const message = useMessage() //
const { t } = useI18n() //
const { product, device } = defineProps<{ product: ProductVO; device: DeviceVO }>() // Props
const emit = defineEmits(['refresh']) // Emits
@ -149,27 +116,28 @@ const emit = defineEmits(['refresh']) // 定义 Emits
const authDialogVisible = ref(false) //
const authPasswordVisible = ref(false) //
const authInfo = ref<IotDeviceAuthInfoVO>({} as IotDeviceAuthInfoVO) //
const mapDialogRef = ref() // Ref
/** 控制地图显示的标志 */
const showMap = computed(() => {
/** 是否有位置信息 */
const hasLocation = computed(() => {
return !!(device.longitude && device.latitude)
})
/** 获取位置字符串,用于地图组件 */
const getLocationString = () => {
if (device.longitude && device.latitude) {
return `${device.longitude},${device.latitude}`
}
return ''
/** 打开地图弹窗 */
const openMapDialog = () => {
mapDialogRef.value?.open(device.longitude, device.latitude)
}
/** 复制到剪贴板方法 */
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
message.success('复制成功')
} catch (error) {
message.error('复制失败')
const { copy, copied, isSupported } = useClipboard({ legacy: true, source: text })
if (!isSupported) {
message.error(t('common.copyError'))
return
}
await copy()
if (unref(copied)) {
message.success(t('common.copySuccess'))
}
}

View File

@ -244,10 +244,14 @@
<script lang="ts" setup>
import { ProductVO } from '@/api/iot/product/product'
import { ThingModelData } from '@/api/iot/thingmodel'
import { DeviceApi, DeviceStateEnum, DeviceVO } from '@/api/iot/device/device'
import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
import DeviceDetailsMessage from './DeviceDetailsMessage.vue'
import { DataDefinition } from '@/views/iot/thingmodel/components'
import { IotDeviceMessageMethodEnum, IoTThingModelTypeEnum } from '@/views/iot/utils/constants'
import {
DeviceStateEnum,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum
} from '@/views/iot/utils/constants'
const props = defineProps<{
product: ProductVO

View File

@ -0,0 +1,264 @@
<!-- 子设备管理 -->
<template>
<ContentWrap>
<!-- 操作按钮 -->
<div class="mb-4">
<el-button type="primary" plain @click="openBindDialog" v-hasPermi="['iot:device:update']">
<Icon icon="ep:plus" class="mr-5px" /> 添加子设备
</el-button>
<el-button
type="danger"
plain
@click="handleUnbindBatch"
:disabled="selectedIds.length === 0"
v-hasPermi="['iot:device:update']"
>
<Icon icon="ep:delete" class="mr-5px" /> 批量解绑
</el-button>
</div>
<!-- 子设备列表 -->
<el-table
v-loading="loading"
:data="subDeviceList"
:stripe="true"
:show-overflow-tooltip="true"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="DeviceName" align="center" prop="deviceName">
<template #default="{ row }">
<el-link type="primary" @click="openDeviceDetail(row.id)">{{ row.deviceName }}</el-link>
</template>
</el-table-column>
<el-table-column label="备注名称" align="center" prop="nickname" />
<el-table-column label="产品名称" align="center" prop="productName" />
<el-table-column label="设备状态" align="center" prop="state">
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.IOT_DEVICE_STATE" :value="row.state" />
</template>
</el-table-column>
<el-table-column
label="最后上线时间"
align="center"
prop="onlineTime"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="操作" align="center" width="120px">
<template #default="{ row }">
<el-button link type="primary" @click="openDeviceDetail(row.id)"> </el-button>
<el-button
link
type="danger"
@click="handleUnbind(row.id)"
v-hasPermi="['iot:device:update']"
>
解绑
</el-button>
</template>
</el-table-column>
</el-table>
</ContentWrap>
<!-- 添加子设备弹窗 -->
<Dialog title="添加子设备" v-model="bindDialogVisible" width="900px">
<ContentWrap>
<!-- 搜索区域 -->
<el-form :model="bindQueryParams" ref="bindQueryFormRef" :inline="true" class="-mb-15px">
<el-form-item label="产品" prop="productId">
<ProductSelect
v-model="bindQueryParams.productId"
:device-type="DeviceTypeEnum.GATEWAY_SUB"
class="!w-200px"
/>
</el-form-item>
<el-form-item label="设备名称" prop="deviceName">
<el-input
v-model="bindQueryParams.deviceName"
placeholder="请输入设备名称"
clearable
class="!w-200px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getBindableDevicePage">
<Icon icon="ep:search" class="mr-5px" /> 搜索
</el-button>
<el-button @click="resetBindQuery">
<Icon icon="ep:refresh" class="mr-5px" /> 重置
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<!-- 分页表格 -->
<el-table
ref="bindTableRef"
v-loading="bindFormLoading"
:data="bindableDevices"
:stripe="true"
:show-overflow-tooltip="true"
@selection-change="handleBindSelectionChange"
max-height="400px"
>
<el-table-column type="selection" width="55" />
<el-table-column label="DeviceName" align="center" prop="deviceName" />
<el-table-column label="备注名称" align="center" prop="nickname" />
<el-table-column label="产品名称" align="center" prop="productName" />
<el-table-column label="设备状态" align="center" prop="state">
<template #default="{ row }">
<dict-tag :type="DICT_TYPE.IOT_DEVICE_STATE" :value="row.state" />
</template>
</el-table-column>
</el-table>
<!-- 分页组件 -->
<Pagination
v-model:page="bindQueryParams.pageNo"
v-model:limit="bindQueryParams.pageSize"
:total="bindTotal"
@pagination="getBindableDevicePage"
/>
</ContentWrap>
<template #footer>
<el-button type="primary" @click="handleBindSubmit" :loading="bindFormLoading">
确定已选 {{ bindSelectedIds.length }}
</el-button>
<el-button @click="bindDialogVisible = false">取消</el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
import { DeviceTypeEnum } from '@/api/iot/product/product'
import { DICT_TYPE } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime'
import ProductSelect from '@/views/iot/product/product/components/ProductSelect.vue'
const props = defineProps<{
gatewayId: number
}>()
const message = useMessage()
const { push } = useRouter()
const loading = ref(false) //
const subDeviceList = ref<DeviceVO[]>([]) //
const selectedIds = ref<number[]>([]) // ID
const bindDialogVisible = ref(false) //
const bindFormLoading = ref(false) //
const bindTableRef = ref()
const bindQueryFormRef = ref()
const bindableDevices = ref<DeviceVO[]>([]) //
const bindSelectedIds = ref<number[]>([]) // ID
const bindTotal = ref(0) //
const bindQueryParams = reactive({
pageNo: 1,
pageSize: 10,
productId: undefined as number | undefined,
deviceName: ''
})
/** 获取子设备列表 */
const getSubDeviceList = async () => {
loading.value = true
try {
subDeviceList.value = await DeviceApi.getSubDeviceList(props.gatewayId)
} finally {
loading.value = false
}
}
/** 打开设备详情 */
const openDeviceDetail = (id: number) => {
push({ name: 'IoTDeviceDetail', params: { id } })
}
/** 多选框选中数据 */
const handleSelectionChange = (selection: DeviceVO[]) => {
selectedIds.value = selection.map((item) => item.id)
}
/** 打开绑定弹窗 */
const openBindDialog = async () => {
bindSelectedIds.value = []
bindDialogVisible.value = true
await getBindableDevicePage()
}
/** 获取可绑定设备分页 */
const getBindableDevicePage = async () => {
bindFormLoading.value = true
try {
const result = await DeviceApi.getUnboundSubDevicePage(bindQueryParams)
bindableDevices.value = result.list
bindTotal.value = result.total
} finally {
bindFormLoading.value = false
}
}
/** 重置绑定弹窗搜索条件 */
const resetBindQuery = () => {
bindQueryParams.pageNo = 1
bindQueryParams.productId = undefined
bindQueryParams.deviceName = ''
getBindableDevicePage()
}
/** 绑定弹窗多选框选中数据 */
const handleBindSelectionChange = (selection: DeviceVO[]) => {
bindSelectedIds.value = selection.map((item) => item.id)
}
/** 提交绑定 */
const handleBindSubmit = async () => {
if (bindSelectedIds.value.length === 0) {
message.warning('请选择要绑定的子设备')
return
}
bindFormLoading.value = true
try {
await DeviceApi.bindDeviceGateway({
subIds: bindSelectedIds.value,
gatewayId: props.gatewayId
})
message.success('绑定成功')
bindDialogVisible.value = false
await getSubDeviceList()
} finally {
bindFormLoading.value = false
}
}
/** 解绑单个设备 */
const handleUnbind = async (id: number) => {
try {
await message.confirm('确定要解绑该子设备吗?')
await DeviceApi.unbindDeviceGateway({ subIds: [id], gatewayId: props.gatewayId })
message.success('解绑成功')
await getSubDeviceList()
} catch {}
}
/** 批量解绑 */
const handleUnbindBatch = async () => {
try {
await message.confirm(`确定要解绑选中的 ${selectedIds.value.length} 个子设备吗?`)
await DeviceApi.unbindDeviceGateway({ subIds: selectedIds.value, gatewayId: props.gatewayId })
message.success('批量解绑成功')
selectedIds.value = []
await getSubDeviceList()
} catch {}
}
/** 初始化 */
onMounted(async () => {
await getSubDeviceList()
})
</script>

View File

@ -99,13 +99,13 @@
<div class="text-[14px]">
<div class="mb-2.5 last:mb-0">
<span class="text-[#717c8e] mr-2.5">属性值</span>
<span class="text-[#0b1d30] font-600">
<span class="text-[var(--el-text-color-primary)] font-600">
{{ formatValueWithUnit(item) }}
</span>
</div>
<div class="mb-2.5 last:mb-0">
<span class="text-[#717c8e] mr-2.5">更新时间</span>
<span class="text-[#0b1d30] text-[12px]">
<span class="text-[var(--el-text-color-primary)] text-[12px]">
{{ item.updateTime ? formatDate(item.updateTime) : '-' }}
</span>
</div>

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