Compare commits

...

45 Commits

Author SHA1 Message Date
芋道源码 9d8d0647be
!877 feat(system): 优化用户选择 UserSelectV2 布局,多选支持、默认选中当前用户支持、禁选支持、默认部门支持,可替代…
Merge pull request !877 from 半栈幼儿员/hotfix/user
2026-05-05 10:10:47 +00:00
preschooler 536e54062e feat(system): 优化用户选择 UserSelectV2 布局,多选支持、默认选中当前用户支持、禁选支持、默认部门支持,可替代项目所有位置,可移除原 UserSelectForm、UserSelect,避免一次性查询所有用户 2026-05-05 12:35:31 +08:00
芋道源码 5e937d797d
!851 feat:增加说明文案
Merge pull request !851 from steven/feat-自定义海报代码补充开发
2026-05-03 10:59:47 +00:00
芋道源码 3a1f520dc6
!876 回退 'Pull Request !870 : fix: 菜单名称过长时没有正确显示省略号'
Merge pull request !876 from 芋道源码/revert-merge-870-master
2026-05-03 10:55:53 +00:00
芋道源码 5e6b6bdd8e
回退 'Pull Request !870 : fix: 菜单名称过长时没有正确显示省略号' 2026-05-03 10:55:35 +00:00
YunaiV 53b96f87a0 Merge remote-tracking branch 'origin/feat/mes' into feat/mes 2026-05-03 18:48:25 +08:00
YunaiV b7a13a0000 ♻️ refactor(service): 优化请求拦截器中的 token 设置逻辑,简化白名单判断 2026-05-03 18:48:12 +08:00
YunaiV 6d5705b655 fix(bpm):修正流程实例审批弹窗网关分支重算的并发与提交问题
- 提交时不再用节点表单值覆盖 data.variables;与预览阶段使用同一份合并变量
- onChange 加 useDebounceFn(300ms) + 请求序号去重,handleAudit 提交前 await 最新一轮重算
- 切换任务时重置请求序号与 pending 重算
- 改用 form-create 官方 formData() 取节点表单当前值
- 双 nextTick 改为 until 等 fApi 就绪,1s 兜底超时
2026-05-03 18:48:12 +08:00
YunaiV 8571a27a15 fix: 【framework】关闭 TagsView 标签后 keep-alive 缓存未收缩,导致 DOM/JS heap 不回收
delView/delAllViews 误用 delCachedView,关闭非当前标签时会去删 currentRoute
对应的缓存,把要关的 name 留在 cachedViews 里,keep-alive include 不收缩,
旧组件实例无法 unmount。

回退到基于剩余 visitedViews 重建 cachedViews 的实现(对应 5718c7881 之前的写法);
delCachedView 自身保留 issue #180 的修复,仍供 refreshPage 使用。
2026-05-03 18:48:12 +08:00
YunaiV cdcd200c7d 【修复】form-create 单图上传规则 disabled 字段标题与默认值错配 2026-05-03 18:48:12 +08:00
YunaiV 6232330c81 【修复】IoT 场景联动:事件触发器比较值改普通文本输入,允许留空(事件发生即匹配) 2026-05-03 18:48:12 +08:00
YunaiV 418f0c4f52 🐛 fix(system):修复租户 get-by-website 接口不支持端口的问题
🐛 fix(mes):修复常见缺陷的「检测项类型」错用独立字典的问题

「常见缺陷」与「检测项设置」的「检测项类型」语义一致,应共用同一份字典;DefectForm 与列表页统一改为 MES_INDICATOR_TYPE,并清理未使用的 MES_DEFECT_TYPE 常量。
2026-05-03 18:48:12 +08:00
YunaiV 5bce60fd29 ♻️ refactor(service): 优化请求拦截器中的 token 设置逻辑,简化白名单判断 2026-05-03 18:48:04 +08:00
YunaiV beddbe7785 Merge remote-tracking branch 'origin/feat/mes' into feat/mes 2026-05-03 18:39:16 +08:00
芋道源码 2e65691737
!870 fix: 菜单名称过长时没有正确显示省略号
Merge pull request !870 from 李家辉/fix-text-overflow
2026-05-03 10:38:57 +00:00
YunaiV 95cecc8870 fix(bpm):修正流程实例审批弹窗网关分支重算的并发与提交问题
- 提交时不再用节点表单值覆盖 data.variables;与预览阶段使用同一份合并变量
- onChange 加 useDebounceFn(300ms) + 请求序号去重,handleAudit 提交前 await 最新一轮重算
- 切换任务时重置请求序号与 pending 重算
- 改用 form-create 官方 formData() 取节点表单当前值
- 双 nextTick 改为 until 等 fApi 就绪,1s 兜底超时
2026-05-03 18:38:18 +08:00
YunaiV 7a4300116e fix: 【framework】关闭 TagsView 标签后 keep-alive 缓存未收缩,导致 DOM/JS heap 不回收
delView/delAllViews 误用 delCachedView,关闭非当前标签时会去删 currentRoute
对应的缓存,把要关的 name 留在 cachedViews 里,keep-alive include 不收缩,
旧组件实例无法 unmount。

回退到基于剩余 visitedViews 重建 cachedViews 的实现(对应 5718c7881 之前的写法);
delCachedView 自身保留 issue #180 的修复,仍供 refreshPage 使用。
2026-05-03 18:38:18 +08:00
YunaiV 51542e336b 【修复】form-create 单图上传规则 disabled 字段标题与默认值错配 2026-05-03 18:38:18 +08:00
YunaiV 7f33206057 【修复】IoT 场景联动:事件触发器比较值改普通文本输入,允许留空(事件发生即匹配) 2026-05-03 18:38:18 +08:00
YunaiV 8e1430c1a4 🐛 fix(system):修复租户 get-by-website 接口不支持端口的问题
🐛 fix(mes):修复常见缺陷的「检测项类型」错用独立字典的问题

「常见缺陷」与「检测项设置」的「检测项类型」语义一致,应共用同一份字典;DefectForm 与列表页统一改为 MES_INDICATOR_TYPE,并清理未使用的 MES_DEFECT_TYPE 常量。
2026-05-03 18:38:18 +08:00
芋道源码 aafe5f12bc
!866 fix: 修复请求拦截器bug
Merge pull request !866 from funcong/fc-fix-bug
2026-05-03 10:37:47 +00:00
YunaiV 9df6828255 fix(bpm):修正流程实例审批弹窗网关分支重算的并发与提交问题
- 提交时不再用节点表单值覆盖 data.variables;与预览阶段使用同一份合并变量
- onChange 加 useDebounceFn(300ms) + 请求序号去重,handleAudit 提交前 await 最新一轮重算
- 切换任务时重置请求序号与 pending 重算
- 改用 form-create 官方 formData() 取节点表单当前值
- 双 nextTick 改为 until 等 fApi 就绪,1s 兜底超时
2026-05-03 16:34:55 +08:00
YunaiV 06e2ca3100 Merge remote-tracking branch 'origin/feat/mes' into feat/mes 2026-05-03 13:33:23 +08:00
YunaiV fa9facfa0b fix: 【framework】关闭 TagsView 标签后 keep-alive 缓存未收缩,导致 DOM/JS heap 不回收
delView/delAllViews 误用 delCachedView,关闭非当前标签时会去删 currentRoute
对应的缓存,把要关的 name 留在 cachedViews 里,keep-alive include 不收缩,
旧组件实例无法 unmount。

回退到基于剩余 visitedViews 重建 cachedViews 的实现(对应 5718c7881 之前的写法);
delCachedView 自身保留 issue #180 的修复,仍供 refreshPage 使用。
2026-05-03 13:32:43 +08:00
YunaiV 0cc2bff0f4 【修复】form-create 单图上传规则 disabled 字段标题与默认值错配 2026-05-03 13:32:43 +08:00
YunaiV 192a118823 【修复】IoT 场景联动:事件触发器比较值改普通文本输入,允许留空(事件发生即匹配) 2026-05-03 13:32:43 +08:00
YunaiV d2e82b710b 🐛 fix(system):修复租户 get-by-website 接口不支持端口的问题
🐛 fix(mes):修复常见缺陷的「检测项类型」错用独立字典的问题

「常见缺陷」与「检测项设置」的「检测项类型」语义一致,应共用同一份字典;DefectForm 与列表页统一改为 MES_INDICATOR_TYPE,并清理未使用的 MES_DEFECT_TYPE 常量。
2026-05-03 13:32:43 +08:00
芋道源码 583b409fad
!864 条件节点添加包含和不包含操作符
Merge pull request !864 from Lesan/bugfix/bpm-202602
2026-05-03 03:01:48 +00:00
YunaiV 5d0755eea9 Merge remote-tracking branch 'origin/feat/mes' into feat/mes 2026-05-03 09:20:21 +08:00
芋道源码 a698cb1635
!867 feat(iot): 前端新增 Database 数据目的配置表单
Merge pull request !867 from puhui999/master
2026-05-03 01:19:11 +00:00
YunaiV a704620f84 fix: 【framework】关闭 TagsView 标签后 keep-alive 缓存未收缩,导致 DOM/JS heap 不回收
delView/delAllViews 误用 delCachedView,关闭非当前标签时会去删 currentRoute
对应的缓存,把要关的 name 留在 cachedViews 里,keep-alive include 不收缩,
旧组件实例无法 unmount。

回退到基于剩余 visitedViews 重建 cachedViews 的实现(对应 5718c7881 之前的写法);
delCachedView 自身保留 issue #180 的修复,仍供 refreshPage 使用。
2026-05-03 00:28:27 +08:00
芋道源码 7fd0a24ca5
!875 fix(bpm):修复流程网关分支问题
Merge pull request !875 from 郭某人/master
2026-05-02 16:01:00 +00:00
YunaiV e98d575b3a 【修复】form-create 单图上传规则 disabled 字段标题与默认值错配 2026-05-02 22:56:56 +08:00
YunaiV d5a9e2e313 【修复】IoT 场景联动:事件触发器比较值改普通文本输入,允许留空(事件发生即匹配) 2026-05-02 14:32:42 +08:00
芋道源码 f9c0cace70
!871 fix: 修复响应拦截器 code=0 的逻辑错误
Merge pull request !871 from zhulh/fix/code-0-bug
2026-05-02 03:21:51 +00:00
YunaiV 2c3842582f 🐛 fix(system):修复租户 get-by-website 接口不支持端口的问题
🐛 fix(mes):修复常见缺陷的「检测项类型」错用独立字典的问题

「常见缺陷」与「检测项设置」的「检测项类型」语义一致,应共用同一份字典;DefectForm 与列表页统一改为 MES_INDICATOR_TYPE,并清理未使用的 MES_DEFECT_TYPE 常量。
2026-05-02 00:35:16 +08:00
guoanhao 11495a64f5 fix(bpm):修复流程网关分支问题 2026-04-22 17:37:30 +08:00
zhulianghu 74128f53a5 fix: 修复响应拦截器 code=0 的逻辑错误 2026-04-01 09:53:32 +08:00
lijiahui 491e09c136 fix: 菜单名称过长时没有正确显示省略号 2026-03-30 15:31:51 +08:00
puhui999 dfee5b999d style(iot): 优化 Database 数据目的的建表提示UX
- 表名输入框右侧附加「查看/收起表结构提示」按钮
- 引入 el-collapse-transition 结合酷炫终端卡片实现平滑的折叠动画
- 修正 Vue template 中由于缺少闭合 div 导致的语法编译错误
2026-03-13 12:46:57 +08:00
puhui999 9f19835a80 feat(iot): Database 表单增加建表 SQL 提示和一键复制
- 顶部 el-alert 友好提示用户需要先创建表
- 内嵌 SQL 文本框(monospace字体) + 复制按钮
- tableName 默认值设为 iot_device_message_sink
2026-03-13 12:30:04 +08:00
puhui999 ad376b24b4 feat(iot): 前端新增 Database 数据目的配置表单
1. DatabaseConfigForm.vue: 新增 Database 配置表单(JDBC地址/用户名/密码/目标表名)
2. config/index.ts: 导出 DatabaseConfigForm 组件
3. DataSinkForm.vue: 引入 DatabaseConfigForm 条件渲染和校验规则
4. api/sink/index.ts: 添加 DatabaseConfig 接口和联合类型
2026-03-13 12:27:09 +08:00
funcong 8cffb4a8ca fix: 修复请求拦截器bug 2026-03-10 18:48:03 +08:00
LesanOuO 80128c5406 feat: 条件节点添加包含和不包含操作符 2026-02-14 16:53:21 +08:00
8614095 3314dfe365 feat:增加说明文案 2026-01-15 11:13:20 +08:00
19 changed files with 601 additions and 124 deletions

View File

@ -13,6 +13,7 @@ export interface DataSinkVO {
| TcpConfig
| WebSocketConfig
| MqttConfig
| DatabaseConfig
| RocketMQConfig
| KafkaMQConfig
| RabbitMQConfig
@ -73,6 +74,14 @@ export interface MqttConfig extends Config {
topic: string
}
/** Database 配置 */
export interface DatabaseConfig extends Config {
jdbcUrl: string
username: string
password: string
tableName: string
}
/** RocketMQ 配置 */
export interface RocketMQConfig extends Config {
nameServer: string

View File

@ -15,6 +15,7 @@ export interface UserVO {
remark: string
loginDate: Date
createTime: Date
disabled?: boolean
}
// 查询用户管理列表
@ -22,6 +23,11 @@ export const getUserPage = (params: PageParam) => {
return request.get({ url: '/system/user/page', params })
}
// 查询用户管理列表
export const getUserList = (ids: number[]) => {
return request.get({ url: '/system/user/list', params: { ids: ids.join(',') } })
}
// 查询用户详情
export const getUser = (id: number) => {
return request.get({ url: '/system/user/get?id=' + id })

View File

@ -74,8 +74,8 @@ export const useUploadImgRule = () => {
{
type: 'switch',
field: 'disabled',
title: '是否显示删除按钮',
value: true
title: '是否禁用',
value: false
},
{
type: 'switch',

View File

@ -629,6 +629,14 @@ export const COMPARISON_OPERATORS: DictDataVO = [
{
value: '<=',
label: '小于等于'
},
{
value: 'contain',
label: '包含'
},
{
value: '!contain',
label: '不包含'
}
]
// 审批操作按钮名称

View File

@ -49,15 +49,13 @@ const service: AxiosInstance = axios.create({
// request拦截器
service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 是否需要设置 token
let isToken = (config!.headers || {}).isToken === false
whiteList.some((v) => {
if (config.url && config.url.indexOf(v) > -1) {
return (isToken = false)
}
})
if (getAccessToken() && !isToken) {
config.headers.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token
// 是否需要设置 token命中白名单的接口如 /login不带 token
let isToken = (config!.headers || {}).isToken !== false
if (isToken && whiteList.some((v) => config.url?.includes(v))) {
isToken = false
}
if (getAccessToken() && isToken) {
config.headers.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义 token
}
// 设置租户
if (tenantEnable && tenantEnable === 'true') {
@ -145,7 +143,7 @@ service.interceptors.response.use(
}
data = await new Response(response.data).json()
}
const code = data.code || result_code
const code = data.code ?? result_code
// 获取错误信息
const msg = data.msg || errorCode[code] || errorCode['default']
if (ignoreMsgs.indexOf(msg) !== -1) {
@ -211,7 +209,7 @@ service.interceptors.response.use(
'<div>5 分钟搭建本地环境</div>'
})
return Promise.reject(new Error(msg))
} else if (code !== 200) {
} else if (code !== 0 && code !== 200) {
if (msg === '无效的刷新令牌') {
// hard coding忽略这个提示直接登出
console.log(msg)

View File

@ -78,7 +78,7 @@ export const useTagsViewStore = defineStore('tagsView', {
// 删除某个
delView(view: RouteLocationNormalizedLoaded) {
this.delVisitedView(view)
this.delCachedView()
this.addCachedView()
},
// 删除tag
delVisitedView(view: RouteLocationNormalizedLoaded) {
@ -106,7 +106,7 @@ export const useTagsViewStore = defineStore('tagsView', {
// 删除所有缓存和tag
delAllViews() {
this.delAllVisitedViews()
this.delCachedView()
this.addCachedView()
},
// 删除所有tag
delAllVisitedViews() {

View File

@ -268,7 +268,6 @@ export enum DICT_TYPE {
MES_INDICATOR_TYPE = 'mes_indicator_type', // MES 检测项类型
MES_QC_RESULT_TYPE = 'mes_qc_result_type', // MES 质检结果值类型
MES_DEFECT_LEVEL = 'mes_defect_level', // MES 缺陷等级
MES_DEFECT_TYPE = 'mes_defect_type', // MES 缺陷检测项类型
MES_PRO_WORK_ORDER_STATUS = 'mes_pro_work_order_status', // MES 生产工单状态
MES_PRO_WORK_ORDER_SOURCE_TYPE = 'mes_pro_work_order_source_type', // MES 工单来源类型
MES_PRO_WORK_ORDER_TYPE = 'mes_pro_work_order_type', // MES 工单类型

View File

@ -526,6 +526,7 @@ import {
} from '@/components/SimpleProcessDesignerV2/src/consts'
import { BpmModelFormType, BpmProcessInstanceStatus } from '@/utils/constants'
import type { FormInstance, FormRules } from 'element-plus'
import { until, useDebounceFn } from '@vueuse/core'
import SignDialog from './SignDialog.vue'
import ProcessInstanceTimeline from '../detail/ProcessInstanceTimeline.vue'
import { isEmpty } from '@/utils/is'
@ -574,6 +575,8 @@ const signRef = ref()
const approveSignFormRef = ref()
const nextAssigneesActivityNode = ref<ProcessInstanceApi.ApprovalNodeInfo[]>([]) //
const nextAssigneesTimelineRef = ref() // 线
let nextApprovalRequestId = 0 // onChange
let pendingNextNodesTask: Promise<unknown> | null = null // onChange await
const approveReasonForm = reactive({
reason: '',
signPicUrl: '',
@ -582,7 +585,11 @@ const approveReasonForm = reactive({
const approveReasonRule = computed(() => {
return {
reason: [
{ required: reasonRequire.value, message: nodeTypeName.value + '意见不能为空', trigger: 'blur' }
{
required: reasonRequire.value,
message: nodeTypeName.value + '意见不能为空',
trigger: 'blur'
}
],
signPicUrl: [{ required: true, message: '签名不能为空', trigger: 'change' }],
nextAssignees: [{ required: true, message: '审批人不能为空', trigger: 'blur' }]
@ -696,7 +703,6 @@ const openPopover = async (type: string) => {
message.warning('表单校验不通过,请先完善表单!!')
return
}
initNextAssigneesFormField()
}
if (type === 'return') {
// 退
@ -709,6 +715,18 @@ const openPopover = async (type: string) => {
Object.keys(popOverVisible.value).forEach((item) => {
popOverVisible.value[item] = item === type
})
if (type === 'approve') {
// form-create fApi
// approveFormFApi
if (runningTask.value?.formId > 0) {
// 1s until
await until(() => typeof approveFormFApi.value?.validate === 'function')
.toBeTruthy({ timeout: 1000 })
.catch(() => {})
}
//
await initNextAssigneesFormField()
}
// await nextTick()
// formRef.value.resetFields()
}
@ -728,6 +746,8 @@ const closePopover = (type: string, formRef: FormInstance | undefined) => {
/** 流程通过时,根据表单变量查询新的流程节点,判断下一个节点类型是否为自选审批人 */
const initNextAssigneesFormField = async () => {
//
const requestId = ++nextApprovalRequestId
// ,
const variables = getUpdatedProcessInstanceVariables()
const data = await ProcessInstanceApi.getNextApprovalNodes({
@ -735,6 +755,12 @@ const initNextAssigneesFormField = async () => {
taskId: runningTask.value.id,
processVariablesStr: JSON.stringify(variables)
})
//
if (requestId !== nextApprovalRequestId) {
return
}
//
nextAssigneesActivityNode.value = []
if (data && data.length > 0) {
const customApproveUsersData: Record<string, any[]> = {} // Timeline
data.forEach((node: any) => {
@ -763,6 +789,9 @@ const initNextAssigneesFormField = async () => {
}
}
/** onChange 高频触发时合并 300ms 内的连续按键,减少网关查询请求 */
const debouncedInitNextAssigneesFormField = useDebounceFn(initNextAssigneesFormField, 300)
/** 选择下一个节点的审批人 */
const selectNextAssigneesConfirm = (id: string, userList: any[]) => {
approveReasonForm.nextAssignees[id] = userList?.map((item: any) => item.id)
@ -797,6 +826,10 @@ const handleAudit = async (pass: boolean, formRef: FormInstance | undefined) =>
}
if (pass) {
// onChange + +
if (pendingNextNodesTask) {
await pendingNextNodesTask
}
const nextAssigneesValid = validateNextAssignees()
if (!nextAssigneesValid) return
const variables = getUpdatedProcessInstanceVariables()
@ -811,13 +844,10 @@ const handleAudit = async (pass: boolean, formRef: FormInstance | undefined) =>
if (runningTask.value.signEnable) {
data.signPicUrl = approveReasonForm.signPicUrl
}
// approveForm + data
// TODO
// getUpdatedProcessInstanceVariables data.variables
const formCreateApi = approveFormFApi.value
if (Object.keys(formCreateApi)?.length > 0) {
await formCreateApi.validate()
// @ts-ignore
data.variables = approveForm.value.value
}
await TaskApi.approveTask(data)
popOverVisible.value.approve = false
@ -1075,12 +1105,24 @@ const loadTodoTask = (task: any) => {
approveForm.value = {}
runningTask.value = task
approveFormFApi.value = {}
// pending /Promise
nextApprovalRequestId += 1
pendingNextNodesTask = null
reasonRequire.value = task?.reasonRequire ?? false
nodeTypeName.value = task?.nodeType === NodeType.TRANSACTOR_NODE ? '办理' : '审批'
// approve .
// approve
if (task && task.formId && task.formConf) {
const tempApproveForm = {}
const tempApproveForm: { option?: any; rule?: any; value?: any } = {}
setConfAndFields2(tempApproveForm, task.formConf, task.formFields, task.formVariables)
// onChange
tempApproveForm.option.onChange = () => {
//
if (!popOverVisible.value.approve) {
return
}
// useDebounceFn Promise reject catch 'cancelled'
pendingNextNodesTask = debouncedInitNextAssigneesFormField().catch(() => {})
}
approveForm.value = tempApproveForm
} else {
approveForm.value = {} //
@ -1105,9 +1147,17 @@ const validateNormalForm = async () => {
/** 从可以编辑的流程表单字段,获取需要修改的流程实例的变量 */
const getUpdatedProcessInstanceVariables = () => {
const variables = {}
props.writableFields.forEach((field) => {
variables[field] = props.normalFormApi.getValue(field)
})
//
if (props.writableFields?.length && props.normalFormApi) {
props.writableFields.forEach((field) => {
variables[field] = props.normalFormApi.getValue(field)
})
}
// form-create formData()
const nodeFormData = approveFormFApi.value?.formData?.()
if (nodeFormData) {
Object.assign(variables, nodeFormData)
}
return variables
}

View File

@ -30,6 +30,10 @@
v-model="formData.config"
/>
<MqttConfigForm v-if="IotDataSinkTypeEnum.MQTT === formData.type" v-model="formData.config" />
<DatabaseConfigForm
v-if="IotDataSinkTypeEnum.DATABASE === formData.type"
v-model="formData.config"
/>
<RocketMQConfigForm
v-if="IotDataSinkTypeEnum.ROCKETMQ === formData.type"
v-model="formData.config"
@ -69,6 +73,7 @@ import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { CommonStatusEnum } from '@/utils/constants'
import { DataSinkApi, DataSinkVO, IotDataSinkTypeEnum } from '@/api/iot/rule/data/sink'
import {
DatabaseConfigForm,
HttpConfigForm,
KafkaMQConfigForm,
MqttConfigForm,
@ -116,6 +121,9 @@ const formRules = reactive({
'config.password': [{ required: true, message: '密码不能为空', trigger: 'blur' }],
'config.clientId': [{ required: true, message: '客户端 ID 不能为空', trigger: 'blur' }],
'config.topic': [{ required: true, message: '主题不能为空', trigger: 'blur' }],
// Database
'config.jdbcUrl': [{ required: true, message: 'JDBC 连接地址不能为空', trigger: 'blur' }],
'config.tableName': [{ required: true, message: '目标表名不能为空', trigger: 'blur' }],
// RocketMQ
'config.nameServer': [{ required: true, message: 'NameServer 地址不能为空', trigger: 'blur' }],
'config.accessKey': [{ required: true, message: 'AccessKey 不能为空', trigger: 'blur' }],

View File

@ -0,0 +1,280 @@
<template>
<el-form-item label="JDBC 地址" prop="config.jdbcUrl">
<el-input
v-model="config.jdbcUrl"
placeholder="请输入JDBC连接地址jdbc:mysql://localhost:3306/iot_data"
/>
</el-form-item>
<el-form-item label="用户名" prop="config.username">
<el-input v-model="config.username" placeholder="请输入数据库用户名" />
</el-form-item>
<el-form-item label="密码" prop="config.password">
<el-input
v-model="config.password"
placeholder="请输入数据库密码"
show-password
type="password"
/>
</el-form-item>
<el-form-item label="目标表名" prop="config.tableName">
<div style="display: flex; align-items: center; gap: 12px; width: 100%">
<el-input v-model="config.tableName" placeholder="目标表名" style="width: 240px" />
<el-button type="primary" link @click="toggleSqlTip">
<el-icon class="mr-1"><component :is="showSqlTip ? 'ArrowUp' : 'Document'" /></el-icon>
{{ showSqlTip ? '收起表结构提示' : '查看表结构提示' }}
</el-button>
</div>
</el-form-item>
<!-- Redesigned Terminal-style SQL snippet -->
<el-collapse-transition>
<div v-show="showSqlTip" class="terminal-card">
<div class="terminal-header">
<div class="terminal-dots">
<div class="dot red"></div>
<div class="dot yellow"></div>
<div class="dot green"></div>
</div>
<div class="terminal-title">Initialization Required</div>
<button class="terminal-copy-btn" type="button" @click="handleCopySQL">
<el-icon class="copy-icon"><Check v-if="isCopied" /><DocumentCopy v-else /></el-icon>
{{ isCopied ? '已复制' : 'Copy SQL' }}
</button>
</div>
<div class="terminal-body">
<div class="terminal-desc">
目标数据库必须包含以下结构的表才能正常接收数据流转的消息
</div>
<div class="terminal-code-wrapper">
<pre
class="terminal-code"
><code><span class="kw">CREATE</span> <span class="kw">TABLE</span> <span class="identifier">iot_device_message_sink</span> (
<span class="identifier">id</span> <span class="type">VARCHAR</span>(64) <span class="kw">NOT NULL COMMENT</span> <span class="string">'消息ID'</span>,
<span class="identifier">device_id</span> <span class="type">BIGINT</span> <span class="kw">NOT NULL COMMENT</span> <span class="string">'设备编号'</span>,
<span class="identifier">tenant_id</span> <span class="type">BIGINT</span> <span class="kw">NOT NULL DEFAULT</span> <span class="num">0</span> <span class="kw">COMMENT</span> <span class="string">'租户编号'</span>,
<span class="identifier">method</span> <span class="type">VARCHAR</span>(128) <span class="kw">COMMENT</span> <span class="string">'请求方法'</span>,
<span class="identifier">report_time</span> <span class="type">DATETIME</span> <span class="kw">COMMENT</span> <span class="string">'上报时间'</span>,
<span class="identifier">data</span> <span class="type">TEXT</span> <span class="kw">COMMENT</span> <span class="string">'完整消息JSON'</span>,
<span class="identifier">create_time</span> <span class="type">DATETIME</span> <span class="kw">NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT</span> <span class="string">'创建时间'</span>,
<span class="kw">PRIMARY KEY</span> (<span class="identifier">id</span>) <span class="kw">USING BTREE</span>,
<span class="kw">INDEX</span> <span class="identifier">idx_create_time</span> (<span class="identifier">create_time</span> <span class="kw">ASC</span>) <span class="kw">USING BTREE</span>
) <span class="kw">ENGINE</span> = <span class="identifier">InnoDB</span> <span class="kw">CHARACTER SET</span> = <span class="identifier">utf8mb4</span> <span class="kw">COLLATE</span> = <span class="identifier">utf8mb4_unicode_ci</span> <span class="kw">COMMENT</span> = <span class="string">'IoT 设备消息流转目标表'</span>;</code></pre>
</div>
</div>
</div>
</el-collapse-transition>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { Check, DocumentCopy } from '@element-plus/icons-vue'
import { DatabaseConfig, IotDataSinkTypeEnum } from '@/api/iot/rule/data/sink'
import { useClipboard, useVModel } from '@vueuse/core'
import { isEmpty } from '@/utils/is'
import { useMessage } from '@/hooks/web/useMessage'
defineOptions({ name: 'DatabaseConfigForm' })
const props = defineProps<{
modelValue: any
}>()
const emit = defineEmits(['update:modelValue'])
const config = useVModel(props, 'modelValue', emit) as Ref<DatabaseConfig>
const message = useMessage()
const rawSQL = `CREATE TABLE iot_device_message_sink (
id VARCHAR(64) NOT NULL COMMENT '消息ID',
device_id BIGINT NOT NULL COMMENT '设备编号',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户编号',
method VARCHAR(128) COMMENT '请求方法',
report_time DATETIME COMMENT '上报时间',
data TEXT COMMENT '完整消息JSON',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id) USING BTREE,
INDEX idx_create_time (create_time ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'IoT 设备消息流转目标表';`
const isCopied = ref(false)
const showSqlTip = ref(false)
const { copy } = useClipboard()
const toggleSqlTip = () => {
showSqlTip.value = !showSqlTip.value
}
const handleCopySQL = async () => {
await copy(rawSQL)
isCopied.value = true
message.success('建表 SQL 已复制到剪贴板')
setTimeout(() => {
isCopied.value = false
}, 2000)
}
/** 组件初始化 */
onMounted(() => {
if (!isEmpty(config.value)) {
return
}
config.value = {
type: IotDataSinkTypeEnum.DATABASE + '', // 使
jdbcUrl: '',
username: '',
password: '',
tableName: 'iot_device_message_sink'
}
})
</script>
<style scoped>
/* 终端风卡片设计 (Tokyo Night 极客美学) */
.terminal-card {
margin-top: 32px;
margin-bottom: 8px;
border-radius: 12px;
background-color: #1a1b26;
box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.4);
border: 1px solid #24283b;
overflow: hidden;
font-family: 'Fira Code', 'JetBrains Mono', Consolas, Monaco, monospace;
}
.terminal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background-color: #24283b;
border-bottom: 1px solid #16161e;
position: relative;
}
.terminal-dots {
display: flex;
gap: 8px;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
transition: transform 0.2s ease;
}
.dot:hover {
transform: scale(1.2);
}
.dot.red {
background-color: #f7768e;
box-shadow: 0 0 5px rgba(247, 118, 142, 0.4);
}
.dot.yellow {
background-color: #e0af68;
box-shadow: 0 0 5px rgba(224, 175, 104, 0.4);
}
.dot.green {
background-color: #9ece6a;
box-shadow: 0 0 5px rgba(158, 206, 106, 0.4);
}
.terminal-title {
color: #a9b1d6;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.8px;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.terminal-copy-btn {
background: transparent;
border: 1px solid #414868;
color: #a9b1d6;
border-radius: 6px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
display: flex;
align-items: center;
gap: 6px;
font-family: inherit;
}
.terminal-copy-btn:hover {
background: #bb9af7;
border-color: #bb9af7;
color: #1a1b26;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(187, 154, 247, 0.3);
}
.terminal-copy-btn:active {
transform: translateY(0);
}
.copy-icon {
font-size: 14px;
}
.terminal-body {
padding: 20px;
color: #c0caf5;
font-size: 13px;
line-height: 1.6;
}
.terminal-desc {
color: #7dcfff;
margin-bottom: 16px;
font-family: var(--el-font-family);
font-size: 13px;
padding-bottom: 16px;
border-bottom: 1px dashed #292e42;
}
.terminal-code-wrapper {
overflow-x: auto;
border-radius: 8px;
}
.terminal-code {
margin: 0;
white-space: pre;
min-width: max-content;
}
.terminal-code code {
font-family: inherit;
}
/* 手工实现的轻量级 SQL 语法高亮 (Tokyo Night Color Palette) */
.kw {
color: #bb9af7;
} /* 紫色 - 关键字 */
.type {
color: #2ac3de;
} /* 青色 - 数据类型 */
.string {
color: #9ece6a;
} /* 绿色 - 字符串/注释 */
.identifier {
color: #c0caf5;
} /* 浅蓝 - 变量名/默认字色 */
.num {
color: #ff9e64;
} /* 橙色 - 数字 */
/* 定制代码块的滚动条 */
.terminal-code-wrapper::-webkit-scrollbar {
height: 8px;
}
.terminal-code-wrapper::-webkit-scrollbar-thumb {
background: #414868;
border-radius: 4px;
}
.terminal-code-wrapper::-webkit-scrollbar-thumb:hover {
background: #565f89;
}
.terminal-code-wrapper::-webkit-scrollbar-track {
background: transparent;
}
</style>

View File

@ -2,6 +2,7 @@ import HttpConfigForm from './HttpConfigForm.vue'
import TcpConfigForm from './TcpConfigForm.vue'
import WebSocketConfigForm from './WebSocketConfigForm.vue'
import MqttConfigForm from './MqttConfigForm.vue'
import DatabaseConfigForm from './DatabaseConfigForm.vue'
import RocketMQConfigForm from './RocketMQConfigForm.vue'
import KafkaMQConfigForm from './KafkaMQConfigForm.vue'
import RabbitMQConfigForm from './RabbitMQConfigForm.vue'
@ -12,6 +13,7 @@ export {
TcpConfigForm,
WebSocketConfigForm,
MqttConfigForm,
DatabaseConfigForm,
RocketMQConfigForm,
KafkaMQConfigForm,
RabbitMQConfigForm,

View File

@ -128,13 +128,19 @@ const validateTriggers = (_rule: any, value: any, callback: any) => {
callback(new Error(`触发器 ${i + 1}: 物模型标识符不能为空`))
return
}
if (!trigger.operator) {
callback(new Error(`触发器 ${i + 1}: 操作符不能为空`))
return
}
if (trigger.value === undefined || trigger.value === null || trigger.value === '') {
callback(new Error(`触发器 ${i + 1}: 参数值不能为空`))
return
// / operator '='" / "
const isEventOrService =
trigger.type === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST ||
trigger.type === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE
if (!isEventOrService) {
if (!trigger.operator) {
callback(new Error(`触发器 ${i + 1}: 操作符不能为空`))
return
}
if (trigger.value === undefined || trigger.value === null || trigger.value === '') {
callback(new Error(`触发器 ${i + 1}: 参数值不能为空`))
return
}
}
}

View File

@ -80,14 +80,20 @@
:config="serviceConfig"
placeholder="请输入 JSON 格式的服务参数"
/>
<!-- 事件上报参数配置 -->
<JsonParamsInput
v-else-if="triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST"
v-model="condition.value"
type="event"
:config="eventConfig"
placeholder="请输入 JSON 格式的事件参数"
/>
<!-- 事件上报比较值标量填裸值结构体数组填 JSON 整体相等留空则事件发生即匹配 -->
<template v-else-if="triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST">
<el-input
:model-value="condition.value"
@update:model-value="(value) => updateConditionField('value', value)"
placeholder="留空则事件发生即匹配"
/>
<div class="text-12px text-[var(--el-text-color-secondary)] mt-4px leading-relaxed">
标量事件值填裸值
<code class="px-2px">normal</code>结构体数组事件值填合法
JSON
<code class="px-2px">{"level":"high"}</code>
</div>
</template>
<!-- 普通值输入 -->
<ValueInput
v-else
@ -276,19 +282,6 @@ const serviceConfig = computed(() => {
return undefined
})
// - JsonParamsInput
const eventConfig = computed(() => {
if (propertyConfig.value && props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST) {
return {
event: {
name: propertyConfig.value.name || '事件',
outputParams: propertyConfig.value.outputParams || []
}
}
}
return undefined
})
/**
* 更新条件字段
* @param field 字段名

View File

@ -114,7 +114,7 @@
<el-form-item label="分销海报图">
<UploadImgs v-model="formData.brokeragePosterUrls" height="125px" width="75px" />
<el-text class="w-full" size="small" type="info">
个人中心分销海报图片建议尺寸 600x1000
分销海报图片按上传顺序从左往右依次为<strong>个人分享海报</strong><strong>商品推广海报</strong><strong>拼团推广海报</strong>
</el-text>
</el-form-item>
<el-form-item label="一级返佣比例" prop="brokerageFirstPercent">

View File

@ -21,7 +21,7 @@
<el-form-item label="检测项类型" prop="type">
<el-select v-model="formData.type" placeholder="请选择检测项类型" clearable class="!w-1/1">
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.MES_DEFECT_TYPE)"
v-for="dict in getIntDictOptions(DICT_TYPE.MES_INDICATOR_TYPE)"
:key="dict.value"
:label="dict.label"
:value="dict.value"

View File

@ -37,7 +37,7 @@
class="!w-240px"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.MES_DEFECT_TYPE)"
v-for="dict in getIntDictOptions(DICT_TYPE.MES_INDICATOR_TYPE)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
@ -90,7 +90,7 @@
<el-table-column label="缺陷描述" align="center" prop="name" min-width="200" />
<el-table-column label="检测项类型" align="center" prop="type" width="120">
<template #default="scope">
<dict-tag :type="DICT_TYPE.MES_DEFECT_TYPE" :value="scope.row.type" />
<dict-tag :type="DICT_TYPE.MES_INDICATOR_TYPE" :value="scope.row.type" />
</template>
</el-table-column>
<el-table-column label="缺陷等级" align="center" prop="level" width="120">

View File

@ -10,28 +10,25 @@
reset() 清空选中状态供外部重置按钮调用
-->
<template>
<div>
<el-input
v-model="filterText"
class="mb-15px"
clearable
:placeholder="filterPlaceholder"
>
<div class="h-full">
<el-input v-model="filterText" class="p-[15px]" clearable :placeholder="filterPlaceholder">
<template #prefix>
<Icon icon="ep:search" />
</template>
</el-input>
<el-tree
ref="treeRef"
:data="deptList"
:expand-on-click-node="false"
:filter-node-method="filterNode"
:props="defaultProps"
default-expand-all
highlight-current
node-key="id"
@node-click="handleNodeClick"
/>
<el-scrollbar class="!h-[calc(100%-32px-30px)]">
<el-tree
ref="treeRef"
:data="deptList"
:expand-on-click-node="false"
:filter-node-method="filterNode"
:props="defaultProps"
default-expand-all
highlight-current
node-key="id"
@node-click="handleNodeClick"
/>
</el-scrollbar>
</div>
</template>
@ -98,7 +95,11 @@ const reset = () => {
treeRef.value?.setCurrentKey(undefined)
}
defineExpose({ reset })
const setCurrent = (deptId: number) => {
treeRef.value?.setCurrentKey(deptId)
}
defineExpose({ reset, setCurrent })
/** 初始化 */
onMounted(async () => {

View File

@ -6,31 +6,41 @@
Props:
multiple true 多选checkboxfalse 单选radio默认 true
deptId 部门 ID
Events:
selected(rows: UserVO[]) 确认选择后触发单选时数组长度为 1
Expose:
open(selectedIds?: number[]) 打开弹窗可传入已选 ID 用于预选高亮
-->
<template>
<Dialog title="人员选择" v-model="dialogVisible" width="80%">
<el-row :gutter="20">
<Dialog :title="title" v-model="dialogVisible" width="80%" align-center append-to-body>
<el-row class="h-[calc(100vh-196px)]" :gutter="15">
<!-- 左侧部门树 -->
<el-col :span="5" :xs="24">
<ContentWrap class="h-full">
<el-col class="h-full" :span="5" :xs="24">
<ContentWrap class="h-full" :body-style="{ height: '100%', '--el-card-padding': '0px' }">
<DeptTreeSelect ref="deptTreeRef" @node-click="handleDeptNodeClick" />
</ContentWrap>
</el-col>
<!-- 右侧搜索表单 + 用户表格 -->
<el-col :span="19" :xs="24">
<el-col class="h-full overflow-auto" :span="19" :xs="24">
<ContentWrap>
<el-form :inline="true" :model="queryParams" label-width="85px">
<el-form class="-mb-[15px]" :inline="true" :model="queryParams" label-width="72px">
<el-form-item label="用户名称">
<el-input
v-model="queryParams.username"
placeholder="请输入用户名称"
clearable
@keyup.enter="handleQuery"
class="!w-220px"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="用户昵称">
<el-input
v-model="queryParams.nickname"
placeholder="请输入用户昵称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="手机号码">
@ -39,7 +49,7 @@
placeholder="请输入手机号码"
clearable
@keyup.enter="handleQuery"
class="!w-220px"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="状态">
@ -47,7 +57,7 @@
v-model="queryParams.status"
placeholder="请选择状态"
clearable
class="!w-220px"
class="!w-240px"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
@ -68,9 +78,13 @@
</el-form>
</ContentWrap>
<!-- 数据表格单选 radio / 多选 checkbox 统一在一个 table -->
<ContentWrap>
<ContentWrap
class="h-[calc(100%-var(--content-wrap-padding,10px)*2-var(--content-wrap-margin,15px)*2-32px*2-3px*2-2px)] !mb-0"
:body-style="{ height: '100%', padding: 'var(--content-wrap-padding,10px)' }"
>
<el-table
ref="tableRef"
class="!h-[calc(100%-32px-30px+var(--content-wrap-padding,10px))]"
v-loading="loading"
:data="list"
:stripe="true"
@ -85,6 +99,7 @@
<el-table-column
v-if="multiple"
type="selection"
:selectable="selectable"
:reserve-selection="true"
width="50"
align="center"
@ -96,10 +111,12 @@
v-model="selectedRadioId"
:value="row.id"
class="radio-no-label"
:disabled="row.disabled"
@change="handleRadioChange(row)"
/>
</template>
</el-table-column>
<el-table-column label="用户编号" align="center" prop="id" width="150" />
<el-table-column label="用户名称" align="center" prop="username" width="150" />
<el-table-column label="用户昵称" align="left" prop="nickname" min-width="150" />
<el-table-column label="部门" align="center" prop="deptName" width="150" />
@ -109,6 +126,13 @@
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column
label="创建时间"
align="center"
prop="createTime"
:formatter="dateFormatter"
width="180"
/>
</el-table>
<Pagination
:total="total"
@ -131,27 +155,32 @@ import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { CommonStatusEnum } from '@/utils/constants'
import * as UserApi from '@/api/system/user'
import DeptTreeSelect from '@/views/system/dept/components/DeptTreeSelect.vue'
import { dateFormatter } from '@/utils/formatTime'
defineOptions({ name: 'UserSelectDialogV2' })
const props = withDefaults(
defineProps<{
title?: string
multiple?: boolean // true checkboxfalse radio
deptId?: number // ID
}>(),
{
title: '人员选择',
multiple: true
}
)
const message = useMessage()
const emit = defineEmits<{
selected: [rows: UserApi.UserVO[]]
selected: [rows: UserApi.UserVO[], activityId?: any]
}>()
const dialogVisible = ref(false) //
const loading = ref(false) //
const list = ref<UserApi.UserVO[]>([]) //
const total = ref(0) //
const activityId = ref()
// ==================== ====================
const deptTreeRef = ref() // Ref
@ -168,6 +197,12 @@ const selectedRows = ref<UserApi.UserVO[]>([]) // 多选模式:选中行
const selectedRadioId = ref<number>() // ID
const currentRadioRow = ref<UserApi.UserVO>() //
const preSelectedIds = ref<number[]>([]) // ID
const preDisabledIds = ref<number[]>([]) // ID
/** 多选:是否可以选中 */
const selectable = (row: UserApi.UserVO) => {
return !preDisabledIds.value.includes(row.id)
}
/** 多选checkbox 变化 */
const handleSelectionChange = (rows: UserApi.UserVO[]) => {
@ -183,6 +218,9 @@ const handleRadioChange = (row: UserApi.UserVO) => {
/** 单击行:单选模式下点击整行即选中(降低操作成本),多选不处理(避免和 dblclick 冲突) */
const handleRowClick = (row: UserApi.UserVO) => {
if (row.disabled) {
return
}
if (props.multiple) {
return
}
@ -192,6 +230,9 @@ const handleRowClick = (row: UserApi.UserVO) => {
/** 双击行:多选模式切换勾选,单选模式直接确认 */
const handleRowDblClick = (row: UserApi.UserVO) => {
if (row.disabled) {
return
}
if (props.multiple) {
tableRef.value?.toggleRowSelection(row)
return
@ -206,6 +247,7 @@ const queryParams = reactive({
pageNo: 1, //
pageSize: 10, //
username: undefined as string | undefined, //
nickname: undefined as string | undefined, //
mobile: undefined as string | undefined, //
status: CommonStatusEnum.ENABLE as number | undefined, //
deptId: undefined as number | undefined // ID
@ -218,6 +260,9 @@ const getList = async () => {
const data = await UserApi.getUserPage(queryParams)
list.value = data.list
total.value = data.total
list.value.forEach((row) => {
row.disabled = preDisabledIds.value.includes(row.id)
})
await nextTick()
applyPreSelection()
} finally {
@ -273,13 +318,13 @@ const confirmSelect = () => {
message.warning('请至少选择一条数据')
return
}
emit('selected', selectedRows.value)
emit('selected', selectedRows.value, activityId.value)
} else {
if (!currentRadioRow.value) {
message.warning('请选择一条数据')
return
}
emit('selected', [currentRadioRow.value])
emit('selected', [currentRadioRow.value], activityId.value)
}
dialogVisible.value = false
}
@ -287,24 +332,29 @@ const confirmSelect = () => {
// ==================== ====================
/** 打开弹窗,可传入已选 ID 用于预选高亮 */
const open = async (selectedIds?: number[]) => {
const open = async (selectedIds?: number[], disabledIds?: number[], _activityId?: any) => {
preDisabledIds.value = disabledIds ?? []
activityId.value = _activityId
dialogVisible.value = true
// +
queryParams.username = undefined
queryParams.mobile = undefined
queryParams.status = CommonStatusEnum.ENABLE
queryParams.deptId = undefined
queryParams.deptId = props.deptId
queryParams.pageNo = 1
//
selectedRows.value = []
selectedRadioId.value = undefined
currentRadioRow.value = undefined
preSelectedIds.value = selectedIds ?? []
preSelectedIds.value = (selectedIds ?? []).filter((id) => !preDisabledIds.value.includes(id))
// +
await nextTick()
deptTreeRef.value?.reset()
tableRef.value?.clearSelection()
await getList()
if (queryParams.deptId) {
deptTreeRef.value?.setCurrent(queryParams.deptId)
}
}
defineExpose({ open })
</script>

View File

@ -3,15 +3,19 @@
对齐 MdVendorSelect 架构模式
交互显示为只读 el-input点击打开弹窗单选模式进行选择
交互显示为只读 el-input点击打开弹窗进行选择
Props:
modelValue 绑定的用户 IDv-model
defaultCurrentUser 默认选中当前用户
multiple 默认 false
disabled 是否禁用
disabledIds 禁用的用户 ID
clearable 是否允许清空鼠标悬停时显示清除图标
placeholder 占位文字
deptId 部门 ID
Events:
update:modelValue v-model 更新
change(item) 选中用户变化时触发传递完整 UserVO清空时为 undefined
change(item | items) 选中用户变化时触发传递完整 UserVO清空时为 undefined | []
-->
<template>
<div
@ -22,13 +26,15 @@
@mouseenter="hovering = true"
@mouseleave="hovering = false"
>
<el-tooltip :disabled="!selectedItem" placement="top" :show-after="500">
<el-tooltip :disabled="selectedItems.length === 0" placement="top" :show-after="500">
<template #content>
<div v-if="selectedItem" class="leading-6">
<div>用户名称{{ selectedItem.username }}</div>
<div>用户昵称{{ selectedItem.nickname }}</div>
<div>部门{{ (selectedItem as any).deptName || '-' }}</div>
<div>手机号码{{ selectedItem.mobile || '-' }}</div>
<div v-if="selectedItems.length > 0" class="flex gap-[10px]">
<div v-for="selectedItem in selectedItems" :key="selectedItem.id" class="leading-6">
<div>用户名称{{ selectedItem.username }}</div>
<div>用户昵称{{ selectedItem.nickname }}</div>
<div>部门{{ (selectedItem as any).deptName || '-' }}</div>
<div>手机号码{{ selectedItem.mobile || '-' }}</div>
</div>
</div>
</template>
<el-input
@ -42,13 +48,20 @@
</el-tooltip>
</div>
<!-- 弹窗必须放在 div 外部否则弹窗内的点击事件会冒泡到 div 触发 handleClick -->
<UserSelectDialogV2 ref="dialogRef" :multiple="false" @selected="handleSelected" />
<!-- Dialog append-to-body 即可-->
<UserSelectDialogV2
ref="dialogRef"
:multiple="multiple"
:deptId="deptId"
@selected="handleSelected"
/>
</template>
<script setup lang="ts">
import * as UserApi from '@/api/system/user'
import { Search, CircleClose } from '@element-plus/icons-vue'
import UserSelectDialogV2 from './UserSelectDialogV2.vue'
import { useUserStoreWithOut } from '@/store/modules/user'
// div + DialogVue attrs
// div class / style
@ -58,12 +71,17 @@ defineOptions({ name: 'UserSelectV2', inheritAttrs: false })
const props = withDefaults(
defineProps<{
modelValue?: number // ID
modelValue?: number | number[] // ID
defaultCurrentUser?: boolean //
multiple?: boolean //
disabled?: boolean //
disabledIds?: number[] // ID
clearable?: boolean //
placeholder?: string //
deptId?: number // ID
}>(),
{
defaultCurrentUser: false,
disabled: false,
clearable: true,
placeholder: '请选择用户'
@ -71,19 +89,19 @@ const props = withDefaults(
)
const emit = defineEmits<{
'update:modelValue': [value: number | undefined]
change: [item: UserApi.UserVO | undefined]
'update:modelValue': [value: number | number[] | undefined]
change: [item: UserApi.UserVO | UserApi.UserVO[] | undefined]
}>()
const dialogRef = ref() // Ref
const hovering = ref(false) //
// ==================== ====================
const selectedItem = ref<UserApi.UserVO | undefined>() //
const selectedItems = ref<UserApi.UserVO[]>([]) //
/** 输入框显示文本:展示用户昵称,保持简洁 */
const displayLabel = computed(() => {
return selectedItem.value?.nickname ?? ''
return selectedItems.value.map((item) => item.nickname || item.username).join('、')
})
/** 是否显示清除图标 */
@ -96,17 +114,21 @@ const suffixIcon = computed(() => {
return showClear.value ? CircleClose : Search
})
/** 根据 ID 单条查询用户信息(用于编辑回显) */
const resolveItemById = async (id: number | undefined) => {
if (id == null) {
selectedItem.value = undefined
/** 根据 ID 查询用户信息(用于编辑回显) */
const resolveItemById = async (id: number | number[] | undefined) => {
if (id === null || id === undefined) {
selectedItems.value = []
return
}
if (selectedItem.value?.id === id) {
const ids: number[] = Array.isArray(id) ? id : [id]
if (
selectedItems.value.length === ids.length &&
selectedItems.value.every((item) => ids.includes(item.id))
) {
return
}
try {
selectedItem.value = await UserApi.getUser(id)
selectedItems.value = await UserApi.getUserList(ids)
} catch (e) {
console.error('[UserSelectV2] resolveItemById failed:', e)
}
@ -118,7 +140,7 @@ watch(
(val) => {
resolveItemById(val)
},
{ immediate: true }
{ deep: true, immediate: true }
)
// ==================== ====================
@ -132,14 +154,23 @@ const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (showClear.value && target.closest('.el-input__suffix')) {
e.stopPropagation()
selectedItem.value = undefined
emit('update:modelValue', undefined)
emit('change', undefined)
selectedItems.value = []
if (props.multiple) {
emit('update:modelValue', [])
emit('change', [])
} else {
emit('update:modelValue', undefined)
emit('change', undefined)
}
return
}
// ID
const selectedIds = props.modelValue != null ? [props.modelValue] : []
dialogRef.value.open(selectedIds)
const selectedIds = props.multiple
? props.modelValue || []
: props.modelValue !== null && props.modelValue !== undefined
? [props.modelValue]
: []
dialogRef.value.open(selectedIds, props.disabledIds)
}
/** 弹窗选中回调 */
@ -147,11 +178,47 @@ const handleSelected = (rows: UserApi.UserVO[]) => {
if (!rows || rows.length === 0) {
return
}
const item = rows[0]
selectedItem.value = item
emit('update:modelValue', item.id)
emit('change', item)
selectedItems.value = rows
if (props.multiple) {
emit(
'update:modelValue',
rows.map((item) => item.id)
)
emit('change', rows)
} else {
const item = rows[0]
emit('update:modelValue', item.id)
emit('change', item)
}
}
/** 检查是否有有效的预设值 */
const hasValidPresetValue = (): boolean => {
const value = props.modelValue as any
if (value === undefined || value === null || value === '') {
return false
}
if (Array.isArray(value)) {
return value.length > 0
}
return true
}
onMounted(() => {
/** 默认选中当前用户 */
if (props.defaultCurrentUser && !hasValidPresetValue()) {
const userStore = useUserStoreWithOut()
const user = userStore.getUser
const currentUserId = user?.id
if (currentUserId) {
if (props.multiple) {
emit('update:modelValue', [currentUserId])
} else {
emit('update:modelValue', currentUserId)
}
}
}
})
</script>
<style lang="scss" scoped>