Files
lc_frontend/src/components/LowDesign/src/shareControl/UserSelect.vue
yang chen 5d07ca6d0e feat(bpm): 新增任务分配规则支持自定义用户选择
- 在任务分配规则中增加 userSelects 类型支持
- 新增 TaskSelectAssigneeForm 组件用于选择任务候选人
- 更新 UserSelect 组件导出 Column 接口- 调整任务分配表单逻辑以适配新的用户选择类型- 优化任务分配规则获取与展示逻辑
2025-10-30 15:22:30 +08:00

857 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="user-select-box w-100%">
<div class="user-input" id="userInput" :class="[{ disabled }, type, size]" @click="openTableSelect">
<template v-if="selectId.length">
<div class="user-text-list" v-if="props.column?.multiple">
<el-tag
v-for="id in selectId"
:key="id"
:closable="!disabled && !column.readonly"
type="info"
:size="size"
@close="tagValueClose(id)"
>
{{ getCurrText(id) }}
</el-tag>
</div>
<div v-else class="px-11px overflow-hidden text-ellipsis ws-nowrap">
{{ getCurrText(selectId[0]) }}
<span
v-if="['add', 'edit'].includes(type) && props.column?.clearable !== false"
class="user-input-clear pos-absolute right-5px top-50% cursor-pointer hidden"
style="transform: translateY(-50%)"
@click.stop="tagValueClose(selectId[0])"
>
<Icon :size="14" color="#909399" icon="ep:circle-close"></Icon>
</span>
</div>
</template>
<div class="empty-text" v-else>{{ placeholderText }}</div>
<div class="user-num" v-if="column.multiple && selectId.length >= 2">
{{ t('Avue.control.selectPrepend') }}
<span>{{ selectId.length }}</span>
{{ t('Avue.control.selectpeople') }}
</div>
</div>
<DesignPopup
v-if="['add', 'edit'].includes(type)"
v-model="dialogData.value"
v-model:isFull="dialogData.isFull"
v-bind="dialogData.params"
>
<template #default>
<el-container class="h-100%">
<el-aside width="200px">
<avue-tabs ref="tasbRef" :option="tabsOption" @change="tabsHandleChange"></avue-tabs>
<div class="tree-box" v-show="tabValue.prop == 'dept'">
<avue-tree
ref="deptRef"
:option="treeDeptOption"
:data="currTreeDeptData"
@node-click="nodeClick"
>
<template #default="{ data }">
<span
class="el-tree-node__label"
:class="{ active: data[treeDeptOption.props.value] == deptId }"
>
{{ data[treeDeptOption.props.label] }}
</span>
</template>
</avue-tree>
</div>
<div class="tree-box" v-show="tabValue.prop == 'role'">
<avue-tree
ref="roleRef"
:option="treeRoleOption"
:data="currRoleData"
@node-click="nodeClick"
>
<template #default="{ data }">
<span
class="el-tree-node__label"
:class="{ active: data[treeRoleOption.props.value] == roleId }"
>
{{ data[treeRoleOption.props.label] }}
</span>
</template>
</avue-tree>
</div>
</el-aside>
<el-main class="pb-0!">
<avue-crud
ref="crudRef"
v-model:page="tablePage"
v-model:search="tableSearch"
:table-loading="loading"
:option="Object.assign(tableOption, { calcHeight })"
:data="tableData"
@search-change="searchChange"
@search-reset="resetChange"
@size-change="sizeChange"
@current-change="currentChange"
@selection-change="selectionChange"
@select-all="selectAll"
>
<!-- 单选 -->
<template #lowSelectRadio="{ row, index }">
<el-radio
class="low-select-radio"
v-model="radioValue"
:label="row.userId"
:disabled="!tableOption['selectable'](row)"
@click="radioClick(row, index)"
/>
</template>
<template #nickName="{ row }">
<div class="flex items-center justify-center">
<el-avatar
class="mr-5px"
v-if="column.avatar && row.avatar"
:size="22"
:src="row.avatar"
/>
<el-avatar class="mr-5px" v-else-if="column.avatar" :size="22">
{{ row.nickName && row.nickName.substring(0, 1) }}
</el-avatar>
<span>{{ row.nickName }}</span>
</div>
</template>
</avue-crud>
</el-main>
<el-aside width="200px">
<div
class="h-100% flex-basis-200px flex-shrink-0"
:style="{ borderLeft: 'var(--el-border)' }"
>
<div
class="box-border h-31px pb-5px pt-5px text-center"
:style="{ borderBottom: 'var(--el-border)' }"
>
<span>{{ t('Avue.control.selectPrepend') }}{{ tableSelectId.length }}</span>
<el-button
class="mt--3px p-0"
type="primary"
link
v-if="tableSelectId.length"
@click="clearSelect"
>
{{ t('Avue.crud.emptyBtn') }}
</el-button>
</div>
<div
class="box-border flex flex-wrap items-center overflow-x-hidden overflow-y-auto p-10px"
:style="{
height: 'calc(100% - 31px)',
columnGap: '6px',
rowGap: '6px',
alignContent: 'start'
}"
>
<el-tag
v-for="id in tableSelectId"
:key="id"
:closable="true"
type="info"
:size="size"
class="h-auto! py-4px! ws-break-spaces! break-anywhere! line-height-none"
@close="tagTableClose(id)"
>
{{ getCurrText(id) }}
</el-tag>
</div>
</div>
</el-aside>
</el-container>
</template>
</DesignPopup>
<DesignPopup
v-else
v-model="detailDialog"
:width="800"
:title="`<span>${column.label}</span><span class='text-14px'>${t('Avue.control.totalPrepend')} ${selectId.length} ${t('Avue.control.selectpeople')}</span>`"
:dialogParams="{ alignCenter: true }"
>
<template #default>
<div class="box-border flex p-20px h-100%">
<div
class="box-border flex flex-wrap items-center overflow-x-hidden overflow-y-auto"
:style="{
height: '100%',
columnGap: '6px',
rowGap: '6px',
alignContent: 'start'
}"
>
<el-tag
v-for="id in selectId"
:key="id"
type="info"
:size="size"
class="h-auto! py-4px! ws-break-spaces! break-anywhere! line-height-none"
>
{{ getCurrText(id) }}
</el-tag>
</div>
</div>
</template>
</DesignPopup>
</div>
</template>
<script setup lang="ts">
import { listToTree, filter } from '@/utils/tree'
import { useLowStoreWithOut } from '@/store/modules/low'
import * as DicApi from '@/api/design/dic/index'
import { cloneDeep } from 'lodash-es'
import { object } from 'vue-types'
defineOptions({ name: 'UserSelect' })
/**
* findType 参数解析
* all 所有
* now 本级
* sub 本级和所有下级
* direct_sub 直接下级
* all_sub 所有下级
*/
export interface Column {
label: string
findType: 'all' | 'now' | 'sub' | 'all_sub' | 'direct_sub' //查询类型
columnKey: Array<'mobile' | 'email' | 'sex' | 'post' | 'deptName'> //扩展显示列
disabledIds?: Array<string> //禁止选择的用户id
assignDeptIds?: Array<string> //指定的部门
assignRoleIds?: Array<string> //指定的角色
filterDeptIds?: Array<string> //过滤的部门
filterRoleIds?: Array<string> //过滤的角色
defaultSearchDept?: string //默认部门id
defaultSearchRole?: string //默认角色id
hideTabsKey?: string[] //需要隐藏的tabdept role
requiredKey?: string[] //必填dept role
textFormatter?: string //回显名称格式化
avatar?: boolean //是否显示头像
multiple?: boolean //多选
limit?: number //最大选择数
separator?: string //分隔符
readonly?: boolean
placeholder?: string
clearable?: boolean
}
interface Props {
column: Column
prop: string
type: string
size?: 'small' | 'large' | 'default'
disabled?: boolean
scope?: object
func: (value) => void
}
const props = defineProps<Props>()
const model = defineModel<string | number>()
const emit = defineEmits(['set-form-data'])
const { t } = useI18n()
const lowStore = useLowStoreWithOut()
const dicKey = 'userSelect'
const columnObj = {
nickName: { label: t('Avue.control.userColumn.nickName'), search: true, minWidth: 100 },
mobile: { label: t('Avue.control.userColumn.mobile'), search: true, minwidth: 120 },
email: { label: t('Avue.control.userColumn.email'), minwidth: 140, overHidden: true },
sex: { label: t('Avue.control.userColumn.sex'), minWidth: 50 },
post: { label: t('Avue.control.userColumn.post'), minWidth: 100, overHidden: true },
deptName: { label: t('Avue.control.dept'), minWidth: 100, overHidden: true }
}
const deptConfig = { id: 'deptId', pid: 'deptPid', children: 'children' }
const dialogData = ref({
value: false,
isFull: false,
params: {
title: t('Avue.control.select') + ' ' + (props.column.label || t('Avue.control.user')),
footerBtn: [
{
params: {},
name: t('Avue.common.cancelBtn'),
icon: 'material-symbols:close-rounded',
clickFun: () => {
dialogData.value.value = false
}
},
{
params: { type: 'primary' },
name: t('Avue.common.submitBtn'),
icon: 'material-symbols:check-rounded',
clickFun: () => {
model.value = getCurrTableSelect('confirm').join(',')
props.func?.(tableSelect.value) // 使用可选链
setTimeout(() => {
dialogData.value.value = false
}, 30)
}
}
]
}
})
const detailDialog = ref(false)
const tabsOption = computed(() => {
const option = {
column: [
{ label: t('Avue.control.userColumn.tabsDept'), prop: 'dept' },
{ label: t('Avue.control.userColumn.tabsRole'), prop: 'role' }
]
}
const { hideTabsKey } = props.column
if (hideTabsKey?.length) {
option.column = option.column.filter((tab) => !hideTabsKey.includes(tab.prop))
}
return option
})
const tabValue = ref(tabsOption.value.column[0])
const treeDeptOption = ref({
addBtn: false,
nodeKey: 'deptId',
props: { label: 'deptName', value: 'deptId', childern: 'childern' }
})
const treeDeptData = ref([])
const currTreeDeptData = computed(() => {
const { assignDeptIds, filterDeptIds } = props.column
const isAssign = !!assignDeptIds?.length
const isfilter = !!filterDeptIds?.length
if (isAssign || isfilter) {
return filter(
treeDeptData.value,
(item: any) => {
if (isAssign && assignDeptIds.includes(item.deptId)) {
return true
}
if (isfilter && !filterDeptIds.includes(item.deptId)) {
return true
}
return false
},
deptConfig
)
} else return treeDeptData.value
})
const deptId = ref('')
const treeRoleOption = ref({
addBtn: false,
nodeKey: 'roleId',
props: { label: 'roleName', value: 'roleId' }
})
const roleData = ref([])
const currRoleData = computed(() => {
const { assignRoleIds, filterRoleIds } = props.column
const isAssign = !!assignRoleIds?.length
const isfilter = !!filterRoleIds?.length
if (isAssign || isfilter) {
return roleData.value.filter((item: any) => {
if (isAssign && assignRoleIds.includes(item.roleId)) {
return true
}
if (isfilter && !filterRoleIds.includes(item.roleId)) {
return true
}
return false
})
} else return roleData.value
})
const roleId = ref('')
const loading = ref(true)
const tableOption = ref({
rowKey: 'userId',
height: 'auto',
addBtn: false,
selection: true,
refreshBtn: false,
columnBtn: false,
searchShowBtn: false,
header: false,
menu: false,
reserveSelection: true,
tip: false,
searchMenuSpan: 8,
searchSpan: 8,
column: {},
selectable: (row) => {
if (props.column.limit) {
const leng = tableSelect.value.length
if (leng >= props.column.limit && !tableSelectId.value.includes(row.userId)) return false
}
if (props.column.disabledIds) {
const disabledIds = props.column.disabledIds.map((id) => id + '')
return !disabledIds.includes(row.userId)
}
return true
}
})
const tableData = ref<any>([])
const tablePage = ref({ total: 1, currentPage: 1, pageSize: 10 })
const tableSearch = ref({})
const tableSelect = ref<any>([])
const tableSelectId = ref<Array<number | string>>([])
const radioValue = ref('')
const valueTimer = ref<any>(null)
const crudRef = ref()
const deptRef = ref()
const roleRef = ref()
const tasbRef = ref()
const { initTableLayout, windowSize } = useCrudHeight(crudRef)
const selectId = computed(() => {
if (!model.value) return []
if (typeof model.value != 'string') {
try {
return [(model.value as Number).toString()]
} catch (error) {
return []
}
}
return model.value.split(',')
})
const calcHeight = computed(() => {
return dialogData.value.isFull ? 60 : windowSize.height.value * 0.1 + 100 + 20
})
const placeholderText = computed(() => {
let text = ''
if (!props.disabled && !props.column.readonly) {
text =
props.column.placeholder ||
`${t('Avue.control.select')} ${props.column.label || t('Avue.control.user')}`
}
return text
})
const getCurrText = (id) => {
const text = lowStore.dicObj[dicKey]?.[id] || ''
const formatter = props.column.textFormatter
if (formatter) {
return formatter.replace(/{dicCode}/g, id).replace(/{dicText}/g, text)
}
return text || id
}
const getTableData = () => {
return new Promise(async (resolve) => {
loading.value = true
const apiData = {
pageNo: tablePage.value.currentPage,
pageSize: tablePage.value.pageSize,
fieldList: ['userId', 'nickName', ...props.column.columnKey],
type: props.column.findType,
...tableSearch.value
}
if (props.column.avatar) apiData.fieldList.push('avatar')
if (deptId.value) apiData['deptId'] = deptId.value
if (roleId.value) apiData['roleId'] = roleId.value
const data = await DicApi.getUserSelectList(apiData)
tableData.value = data.records
tablePage.value.total = data.total
loading.value = false
resolve(true)
})
}
const getDeptAndRoleData = () => {
DicApi.getUserSelectDeptList(props.column.findType).then((data) => {
treeDeptData.value = listToTree(data, deptConfig)
nextTick(() => {
if (deptId.value) deptRef.value.setCurrentKey(deptId.value)
})
})
DicApi.getUserSelectRoleList().then((data) => {
roleData.value = data
nextTick(() => {
if (roleId.value) roleRef.value.setCurrentKey(roleId.value)
})
})
}
const tabsHandleChange = (column) => {
tabValue.value = column
}
const nodeClick = (data) => {
const { requiredKey, hideTabsKey, defaultSearchRole, defaultSearchDept } = props.column
if (tabValue.value.prop == 'dept') {
if (deptId.value == data.deptId) {
//不是必填 清空部门id
if (!requiredKey?.includes('dept')) {
deptId.value = ''
deptRef.value.setCurrentKey(null)
}
} else deptId.value = data.deptId
//判断是否清空角色
if (!(hideTabsKey?.includes('role') && defaultSearchRole)) {
roleId.value = ''
roleRef.value.setCurrentKey(null)
}
} else if (tabValue.value.prop == 'role') {
if (roleId.value == data.roleId) {
//不是必填 清空角色id
if (!requiredKey?.includes('role')) {
roleId.value = ''
roleRef.value.setCurrentKey(null)
}
} else roleId.value = data.roleId
//判断是否清空部门
if (!(hideTabsKey?.includes('dept') && defaultSearchDept)) {
deptId.value = ''
deptRef.value.setCurrentKey(null)
}
}
resetChange()
}
const radioClick = (row, index) => {
const bool = tableOption.value['selectable'](row)
if (!bool) return
if (row.userId == radioValue.value) {
setTimeout(() => {
radioValue.value = ''
tableSelect.value = []
}, 30)
} else {
tableSelect.value = [row]
}
}
const getCurrTableSelect = (type?) => {
const dicObj = {}
const textList: string[] = []
const ids = tableSelect.value.map((item) => {
if (item.userId && item.nickName) {
dicObj[item.userId] = item.nickName
textList.push(item.nickName)
}
return item.userId
})
if (type == 'confirm') {
emit('set-form-data', '$' + props.prop, textList.join(props.column.separator || ' | '))
}
lowStore.setDicObj(dicKey, dicObj)
return ids
}
const openTableSelect = () => {
if (props.column['onClick']) props.column['onClick']({ value: model.value, column: props.column })
else if (props.column['click']) {
props.column['click']({ value: model.value, column: props.column })
}
if (props.disabled || props.column.readonly) {
if (selectId.value && selectId.value.length) detailDialog.value = true
return
}
const { defaultSearchDept, defaultSearchRole } = props.column
if (!deptId.value && defaultSearchDept) deptId.value = defaultSearchDept
if (!roleId.value && defaultSearchRole) roleId.value = defaultSearchRole
if (!deptId.value && roleId.value) {
tabValue.value.prop = 'role'
setTimeout(() => {
nextTick(() => {
tasbRef.value.active = '1'
})
}, 10)
}
const title = t('Avue.control.select') + ' ' + (props.column.label || t('Avue.control.user'))
dialogData.value.params.title = title
dialogData.value.value = true
}
const setTableSelect = (ids) => {
const dicObj = {}
const rows: Array<any> = []
tableData.value.forEach((item) => (dicObj[item.userId] = item))
ids.forEach((id) => {
if (dicObj[id]) rows.push(dicObj[id])
else rows.push({ id })
})
if (tableOption.value['column']['lowSelectRadio']) {
tableSelect.value = rows
radioValue.value = rows[0]?.userId || ''
} else {
crudRef.value.clearSelection()
crudRef.value.toggleSelection(rows, true)
}
}
const selectAll = () => {
if (props.column.limit) {
const limit = props.column.limit
if (tableSelect.value.length > limit) {
const clearSelect = tableSelect.value.filter((item, index) => limit <= index)
crudRef.value.toggleSelection(clearSelect)
}
}
}
const tagValueClose = (id) => {
let list = model.value ? (model.value + '').split(',') : []
list = list.filter((key) => key != id)
model.value = list.join(',')
}
const tagTableClose = (id) => {
if (props.column.multiple) {
const selelctData = crudRef.value.$refs.table.getSelectionRows()
const currRow = selelctData.filter((item) => item.userId == id)
crudRef.value.toggleSelection(currRow, false)
} else {
tableSelect.value = []
radioValue.value = ''
}
}
const selectionChange = (data) => {
tableSelect.value = data
}
const searchChange = (params, done) => {
tablePage.value['currentPage'] = 1
getTableData().finally(() => {
done()
})
}
const resetChange = () => {
for (let prop in tableSearch.value) tableSearch.value[prop] = ''
searchChange({}, () => {})
}
const sizeChange = (pageSize) => {
tablePage.value['pageSize'] = pageSize
resetChange()
}
const currentChange = (currentPage) => {
tablePage.value['currentPage'] = currentPage
getTableData()
}
const clearSelect = () => {
if (tableOption.value['column']['lowSelectRadio']) {
tableSelect.value = []
radioValue.value = ''
} else crudRef.value.clearSelection()
}
const initSelect = (val) => {
delete tableOption.value.column['lowSelectRadio']
tableOption.value.selection = val || false
if (!val) {
tableOption.value.column = {
lowSelectRadio: {
label: '',
display: false,
width: 50,
overHidden: false,
fixed: true,
showColumn: false
},
...tableOption.value.column
}
}
}
watch(
() => model.value,
(value) => {
const selectObj = {}
cloneDeep(tableSelect.value).forEach(
(item) => (selectObj[item[tableOption.value.rowKey]] = item).value
)
const changeObj = { value, column: props.column, selectObj, row: undefined }
if (props.scope?.['row']) changeObj['row'] = props.scope['row']
if (props.column['onChange']) props.column['onChange'](changeObj)
else if (props.column['change']) props.column['change'](changeObj)
}
)
watch(
() => props.column.multiple,
(val) => initSelect(val),
{ immediate: true }
)
watch(
() => props.column.columnKey,
(val) => {
const column = { nickName: columnObj.nickName }
val.forEach((prop) => (column[prop] = { ...columnObj[prop] }))
Object.keys(tableOption.value.column).forEach((prop) => {
if (prop != 'lowSelectRadio') delete tableOption.value.column[prop]
})
tableOption.value.column = { ...tableOption.value.column, ...column }
},
{ immediate: true }
)
watch(
() => dialogData.value.isFull,
() => initTableLayout()
)
watch(
() => dialogData.value.value,
(val) => {
clearInterval(valueTimer.value)
if (!val) {
if (crudRef.value) crudRef.value.clearSelection()
deptId.value = ''
roleId.value = ''
deptRef.value.setCurrentKey(null)
roleRef.value.setCurrentKey(null)
return
}
getDeptAndRoleData()
initTableLayout()
resetChange()
valueTimer.value = setInterval(() => {
if (loading.value == false) {
clearInterval(valueTimer.value)
setTableSelect(selectId.value)
}
}, 100)
}
)
watch(
() => tableSelect.value,
(val) => {
tableSelectId.value = getCurrTableSelect()
dialogData.value.params.footerBtn[1].name = `${t('Avue.common.submitBtn')}${val.length}`
}
)
</script>
<style lang="scss" scoped>
.el-container {
height: 100%;
.el-aside:nth-child(1) {
border-right: var(--el-border);
}
}
::v-deep(.el-tabs__header) {
margin-bottom: 0;
}
.tree-box {
height: calc(100% - 44px);
padding: 10px;
box-sizing: border-box;
.el-tree-node__label {
&.active {
color: var(--el-color-primary);
}
}
}
.select-box {
height: 100%;
padding: 10px;
border-left: var(--el-border);
box-sizing: border-box;
}
.user-input {
display: flex;
height: var(--el-component-size);
font-size: var(--el-text-base);
color: var(--el-input-text-color, var(--el-text-color-regular));
cursor: pointer;
background-color: var(--el-bg-color);
background-image: none;
border: var(--el-border);
border-radius: var(--el-input-border-radius, var(--el-border-radius-base));
box-sizing: border-box;
align-items: center;
.user-text-list {
display: flex;
height: 100%;
flex: 1;
padding: 0 11px;
overflow-x: hidden;
align-items: center;
flex-wrap: nowrap;
column-gap: 6px;
}
.empty-text {
margin: 0 10px;
overflow: hidden;
color: #999;
text-align: left;
flex: 1;
text-wrap: nowrap;
}
.user-num {
height: 100%;
padding-right: 10px;
padding-left: 10px;
line-height: var(--el-component-size);
color: var(--el-text-color-regular);
background-color: var(--el-fill-color-light);
span {
display: inline-block;
padding: 0 2px;
color: var(--el-color-primary);
}
}
&.disabled {
&.edit,
&.add {
background-color: var(--el-disabled-bg-color);
}
&.view {
border: none;
.user-text-list {
padding: 0;
}
.user-num {
margin-right: -20px;
}
}
}
&.small {
height: var(--el-component-size-small);
font-size: var(--el-font-size-extra-small);
.user-num {
height: var(--el-component-size-small);
}
}
&.large {
height: var(--el-component-size-large);
font-size: var(--el-font-size-large);
.user-num {
height: var(--el-component-size-large);
}
}
&:hover {
.table-input-clear {
display: flex;
}
}
}
</style>