Compare commits
45 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
9d8d0647be | |
|
|
536e54062e | |
|
|
5e937d797d | |
|
|
3a1f520dc6 | |
|
|
5e6b6bdd8e | |
|
|
53b96f87a0 | |
|
|
b7a13a0000 | |
|
|
6d5705b655 | |
|
|
8571a27a15 | |
|
|
cdcd200c7d | |
|
|
6232330c81 | |
|
|
418f0c4f52 | |
|
|
5bce60fd29 | |
|
|
beddbe7785 | |
|
|
2e65691737 | |
|
|
95cecc8870 | |
|
|
7a4300116e | |
|
|
51542e336b | |
|
|
7f33206057 | |
|
|
8e1430c1a4 | |
|
|
aafe5f12bc | |
|
|
9df6828255 | |
|
|
06e2ca3100 | |
|
|
fa9facfa0b | |
|
|
0cc2bff0f4 | |
|
|
192a118823 | |
|
|
d2e82b710b | |
|
|
583b409fad | |
|
|
5d0755eea9 | |
|
|
a698cb1635 | |
|
|
a704620f84 | |
|
|
7fd0a24ca5 | |
|
|
e98d575b3a | |
|
|
d5a9e2e313 | |
|
|
f9c0cace70 | |
|
|
2c3842582f | |
|
|
11495a64f5 | |
|
|
74128f53a5 | |
|
|
491e09c136 | |
|
|
dfee5b999d | |
|
|
9f19835a80 | |
|
|
ad376b24b4 | |
|
|
8cffb4a8ca | |
|
|
80128c5406 | |
|
|
3314dfe365 |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -74,8 +74,8 @@ export const useUploadImgRule = () => {
|
|||
{
|
||||
type: 'switch',
|
||||
field: 'disabled',
|
||||
title: '是否显示删除按钮',
|
||||
value: true
|
||||
title: '是否禁用',
|
||||
value: false
|
||||
},
|
||||
{
|
||||
type: 'switch',
|
||||
|
|
|
|||
|
|
@ -629,6 +629,14 @@ export const COMPARISON_OPERATORS: DictDataVO = [
|
|||
{
|
||||
value: '<=',
|
||||
label: '小于等于'
|
||||
},
|
||||
{
|
||||
value: 'contain',
|
||||
label: '包含'
|
||||
},
|
||||
{
|
||||
value: '!contain',
|
||||
label: '不包含'
|
||||
}
|
||||
]
|
||||
// 审批操作按钮名称
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 工单类型
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' }],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 字段名
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -6,31 +6,41 @@
|
|||
|
||||
Props:
|
||||
multiple — true 多选(checkbox),false 单选(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 多选(checkbox),false 单选(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>
|
||||
|
|
|
|||
|
|
@ -3,15 +3,19 @@
|
|||
|
||||
对齐 MdVendorSelect 架构模式
|
||||
|
||||
交互:显示为只读 el-input,点击打开弹窗(单选模式)进行选择
|
||||
交互:显示为只读 el-input,点击打开弹窗进行选择
|
||||
Props:
|
||||
modelValue — 绑定的用户 ID(v-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 + Dialog),Vue 不会自动继承 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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue