feat(oa): add dept select FormCreate control with print formatting
Register type=dept on main app and oa-fc-host, with PC tree-select and
narrow drawer pickers storing {id,name} path snapshots; format dept values
in print and related-summary flatten.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
45d84d9642
commit
2e6e1f47d6
9 files changed
+475
No files matched your search
@@ -0,0 +1,306 @@
|
||||
<!-- FormCreate 自定义控件:部门选择(Fresh Mint · PC 树选 + 窄屏抽屉) -->
|
||||
<template>
|
||||
<div class="oa-dept-select">
|
||||
<template v-if="disabled">
|
||||
<span class="oa-dept-select__readonly">{{ displayText || '—' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isNarrow">
|
||||
<button type="button" class="oa-dept-select__trigger" @click="openDrawer">
|
||||
<span v-if="displayText" class="oa-dept-select__trigger-text">{{ displayText }}</span>
|
||||
<span v-else class="oa-dept-select__placeholder">请选择部门</span>
|
||||
<span class="oa-dept-select__chev">▾</span>
|
||||
</button>
|
||||
<el-drawer
|
||||
v-model="drawerVisible"
|
||||
title="选择部门"
|
||||
direction="btt"
|
||||
size="75%"
|
||||
append-to-body
|
||||
:close-on-click-modal="false"
|
||||
class="oa-dept-drawer"
|
||||
modal-class="protocol-dialog-mask"
|
||||
>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
clearable
|
||||
placeholder="搜索部门名称"
|
||||
class="oa-dept-select__search"
|
||||
/>
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
class="oa-dept-select__tree"
|
||||
:data="treeSelectData"
|
||||
node-key="value"
|
||||
:props="{ label: 'label', children: 'children' }"
|
||||
:filter-node-method="filterNode"
|
||||
:show-checkbox="multiple"
|
||||
:check-strictly="true"
|
||||
:highlight-current="!multiple"
|
||||
default-expand-all
|
||||
@node-click="onNarrowNodeClick"
|
||||
@check="onNarrowCheck"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" @click="confirmDrawer">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<el-tree-select
|
||||
v-else
|
||||
:model-value="treeSelectModel"
|
||||
class="oa-dept-select__tree-select"
|
||||
:data="treeSelectData"
|
||||
:props="{ value: 'value', label: 'label', children: 'children' }"
|
||||
:multiple="multiple"
|
||||
:render-after-expand="false"
|
||||
filterable
|
||||
clearable
|
||||
check-strictly
|
||||
default-expand-all
|
||||
placeholder="请选择部门"
|
||||
style="width: 100%"
|
||||
@update:model-value="onTreeSelectChange"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<span>{{ data.label }}</span>
|
||||
</template>
|
||||
</el-tree-select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { Check, Close } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { fetchDeptTree } from '@/api/admin'
|
||||
import { buildPathLabelMapFromTreeRoots } from '@/utils/tree-path'
|
||||
import {
|
||||
formatDeptDisplay,
|
||||
normalizeDeptModelValue,
|
||||
type OaDeptValueItem
|
||||
} from '@/utils/oa-dept-value'
|
||||
|
||||
defineOptions({ name: 'dept' })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: unknown
|
||||
disabled?: boolean
|
||||
multiple?: boolean
|
||||
max?: number | null
|
||||
formCreateInject?: Record<string, unknown>
|
||||
}>(),
|
||||
{ multiple: true, max: null }
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: OaDeptValueItem | OaDeptValueItem[] | null): void
|
||||
(e: 'change', v: OaDeptValueItem | OaDeptValueItem[] | null): void
|
||||
}>()
|
||||
|
||||
type TreeNode = { value: string; label: string; children?: TreeNode[] }
|
||||
|
||||
const pathMap = ref(new Map<string, string>())
|
||||
const treeSelectData = ref<TreeNode[]>([])
|
||||
const isNarrow = ref(false)
|
||||
const drawerVisible = ref(false)
|
||||
const keyword = ref('')
|
||||
const pendingIds = ref<string[]>([])
|
||||
const treeRef = ref<{
|
||||
filter: (q: string) => void
|
||||
setCheckedKeys: (keys: string[]) => void
|
||||
setCurrentKey: (key: string | null) => void
|
||||
getCheckedKeys: () => string[]
|
||||
getCurrentKey: () => string | null
|
||||
} | null>(null)
|
||||
let mql: MediaQueryList | null = null
|
||||
|
||||
const normalized = computed(() => normalizeDeptModelValue(props.modelValue, !!props.multiple))
|
||||
|
||||
const displayText = computed(() => formatDeptDisplay(normalized.value))
|
||||
|
||||
const treeSelectModel = computed(() => {
|
||||
const n = normalized.value
|
||||
if (props.multiple) {
|
||||
return Array.isArray(n) ? n.map((x) => x.id) : []
|
||||
}
|
||||
return n && !Array.isArray(n) ? n.id : undefined
|
||||
})
|
||||
|
||||
function mapTree(nodes: Api.Dept.DeptItem[]): TreeNode[] {
|
||||
return (nodes || []).map((n) => ({
|
||||
value: n.id,
|
||||
label: n.deptName || n.id,
|
||||
children: n.children?.length ? mapTree(n.children) : undefined
|
||||
}))
|
||||
}
|
||||
|
||||
function emitFromIds(ids: string[]) {
|
||||
const items: OaDeptValueItem[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const id of ids) {
|
||||
const tid = String(id || '').trim()
|
||||
if (!tid || seen.has(tid)) continue
|
||||
seen.add(tid)
|
||||
const name = pathMap.value.get(tid) || tid
|
||||
items.push({ id: tid, name })
|
||||
}
|
||||
if (props.multiple) {
|
||||
const max = props.max != null && Number(props.max) > 0 ? Number(props.max) : null
|
||||
if (max != null && items.length > max) {
|
||||
ElMessage.warning(`最多可选 ${max} 个部门`)
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', items)
|
||||
emit('change', items)
|
||||
} else {
|
||||
const one = items[0] || null
|
||||
emit('update:modelValue', one)
|
||||
emit('change', one)
|
||||
}
|
||||
}
|
||||
|
||||
function onTreeSelectChange(val: string | string[] | null | undefined) {
|
||||
if (props.multiple) {
|
||||
emitFromIds(Array.isArray(val) ? val.map(String) : [])
|
||||
} else {
|
||||
emitFromIds(val ? [String(val)] : [])
|
||||
}
|
||||
}
|
||||
|
||||
function openDrawer() {
|
||||
const n = normalized.value
|
||||
if (props.multiple) {
|
||||
pendingIds.value = Array.isArray(n) ? n.map((x) => x.id) : []
|
||||
} else {
|
||||
pendingIds.value = n && !Array.isArray(n) ? [n.id] : []
|
||||
}
|
||||
drawerVisible.value = true
|
||||
nextTick(() => {
|
||||
if (props.multiple) {
|
||||
treeRef.value?.setCheckedKeys(pendingIds.value)
|
||||
} else {
|
||||
treeRef.value?.setCurrentKey(pendingIds.value[0] || null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onNarrowNodeClick(data: TreeNode) {
|
||||
if (props.multiple) return
|
||||
pendingIds.value = [data.value]
|
||||
treeRef.value?.setCurrentKey(data.value)
|
||||
}
|
||||
|
||||
function onNarrowCheck() {
|
||||
if (!props.multiple) return
|
||||
pendingIds.value = (treeRef.value?.getCheckedKeys() || []).map(String)
|
||||
}
|
||||
|
||||
function confirmDrawer() {
|
||||
let ids = pendingIds.value
|
||||
if (!props.multiple) {
|
||||
const cur = treeRef.value?.getCurrentKey()
|
||||
ids = cur ? [String(cur)] : []
|
||||
} else {
|
||||
ids = (treeRef.value?.getCheckedKeys() || []).map(String)
|
||||
}
|
||||
emitFromIds(ids)
|
||||
drawerVisible.value = false
|
||||
}
|
||||
|
||||
function filterNode(q: string, data: TreeNode) {
|
||||
if (!q) return true
|
||||
return (data.label || '').includes(q)
|
||||
}
|
||||
|
||||
watch(keyword, (q) => {
|
||||
treeRef.value?.filter(q || '')
|
||||
})
|
||||
|
||||
async function loadTree() {
|
||||
try {
|
||||
const tree = (await fetchDeptTree()) || []
|
||||
pathMap.value = buildPathLabelMapFromTreeRoots(tree, { nameKey: 'deptName' })
|
||||
treeSelectData.value = mapTree(tree)
|
||||
} catch {
|
||||
pathMap.value = new Map()
|
||||
treeSelectData.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function syncNarrow(e?: MediaQueryList | MediaQueryListEvent) {
|
||||
const matches = e && 'matches' in e ? e.matches : !!mql?.matches
|
||||
isNarrow.value = matches
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTree()
|
||||
mql = window.matchMedia('(max-width: 767px)')
|
||||
syncNarrow(mql)
|
||||
mql.addEventListener('change', syncNarrow)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
mql?.removeEventListener('change', syncNarrow)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.oa-dept-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.oa-dept-select__readonly {
|
||||
color: var(--mint-text, #0f172a);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.oa-dept-select__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--mint-border, #cbd5e1);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.oa-dept-select__placeholder {
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
.oa-dept-select__trigger-text {
|
||||
color: var(--mint-text, #0f172a);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.oa-dept-select__chev {
|
||||
margin-left: 8px;
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.oa-dept-select__search {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.oa-dept-select__tree {
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user