Files
shanxia2.0/web-admin/src/views/system/dept/index.vue
T
2026-06-21 12:41:39 +08:00

749 lines
23 KiB
Vue

<!-- 组织管理 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2>组织管理</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="组织名称" clearable />
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">按组织名称模糊查询。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增
</el-button>
<el-button class="btn-ghost" @click="toggleExpand">
{{ isExpandAll ? '全部折叠' : '全部展开' }}
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
ref="tableRef"
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:default-expand-all="isExpandAll"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
prop="deptName"
label="组织名称"
min-width="140"
align="left"
header-align="left"
/>
<el-table-column
prop="deptCategoryCode"
label="组织类别"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ getDeptCategoryLabel(row.deptCategoryCode) }}
</template>
</el-table-column>
<el-table-column
prop="status"
label="状态"
min-width="80"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="dictStore.getTagType('sys_status', row.status)" size="small">
{{ dictStore.getLabel('sys_status', row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="leader"
label="负责人"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column
prop="leaderPhone"
label="联系电话"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="160"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item primary" @click="handleAddChild(row)">
新增子组织
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
@close="handleDialogClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="dept-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="上级组织" style="grid-column: 1 / -1">
<el-tree-select
v-model="parentSelectId"
:data="deptTreeSelectData"
node-key="value"
:props="{ label: 'label', children: 'children' }"
check-strictly
filterable
clearable
:disabled="isEdit"
placeholder="请选择上级组织(留空为顶级)"
class="w-full"
>
<template #label="{ value }">
{{ formatDeptPathLabel(value) }}
</template>
</el-tree-select>
</el-form-item>
<el-form-item label="组织名称" prop="deptName" style="grid-column: 1 / -1">
<el-input v-model="formData.deptName" placeholder="请输入组织名称" />
</el-form-item>
<el-form-item label="组织类别">
<el-select v-model="formData.deptCategoryCode" placeholder="请选择组织类别" clearable>
<el-option
v-for="opt in deptCategoryOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="formData.status" placeholder="请选择状态">
<el-option
v-for="opt in statusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="负责人">
<el-select
v-model="leaderUserId"
class="w-full"
filterable
remote
clearable
reserve-keyword
:remote-method="remoteSearchLeaderUsers"
:loading="leaderUserLoading"
placeholder="输入关键词检索用户"
@change="onLeaderUserChange"
>
<el-option
v-for="u in leaderUserOptions"
:key="u.id"
:label="formatLeaderUserLabel(u)"
:value="u.id"
/>
</el-select>
</el-form-item>
<el-form-item label="联系电话">
<el-input
v-model="formData.leaderPhone"
placeholder="可选,选择负责人后自动填入,可修改"
/>
</el-form-item>
<el-form-item label="备注" style="grid-column: 1 / -1">
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.deptName || '组织详情'"
:icon="OfficeBuilding"
:meta="`${viewRow?.leader ? '负责人:' + viewRow.leader : ''}${viewRow?.leader && viewRow?.createTime ? ' · ' : ''}${viewRow?.createTime || ''}`"
:sections="viewSections"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Close,
Check,
View,
Edit,
Delete,
More,
OfficeBuilding,
RefreshLeft,
Search
} from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import {
fetchDeptTree,
fetchCreateDept,
fetchUpdateDept,
fetchDeleteDept,
fetchDictDataOptions,
fetchUserList
} from '@/api/admin'
import { buildTreePathLabelFromFlat } from '@/utils/tree-path'
import { useDictStore } from '@/store/modules/dict'
defineOptions({ name: 'SystemDept' })
const dictStore = useDictStore()
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
const tableData = ref<Api.Dept.DeptItem[]>([])
/** 弹窗上级组织:全量树(不受列表关键词筛选影响) */
const deptTreeForSelect = ref<Api.Dept.DeptItem[]>([])
const deptFlatList = ref<Api.Dept.DeptItem[]>([])
const selectedRows = ref<Api.Dept.DeptItem[]>([])
const loading = ref(false)
const isExpandAll = ref(true)
const tableRef = ref()
const dialogVisible = ref(false)
const dialogTitle = ref('新增组织')
const isEdit = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const editingId = ref<string | null>(null)
const viewVisible = ref(false)
const viewRow = ref<Api.Dept.DeptItem | null>(null)
const formData = reactive({
parentId: '',
deptName: '',
deptCategoryCode: '',
status: '1',
leader: '',
leaderPhone: '',
orderNum: 0,
remark: ''
})
const formRules = {
deptName: [{ required: true, message: '请输入组织名称', trigger: 'blur' }]
}
const deptCategoryOptions = ref<Api.Dict.DictDataOption[]>([])
const statusOptions = ref<Api.Dict.DictDataOption[]>([])
type DeptTreeSelectNode = { value: string; label: string; children?: DeptTreeSelectNode[] }
function flattenDeptTree(nodes: Api.Dept.DeptItem[]): Api.Dept.DeptItem[] {
const out: Api.Dept.DeptItem[] = []
const walk = (list: Api.Dept.DeptItem[]) => {
for (const n of list) {
out.push({ ...n, children: undefined })
if (n.children?.length) walk(n.children)
}
}
walk(nodes || [])
return out
}
function buildDeptTreeSelectData(nodes: Api.Dept.DeptItem[]): DeptTreeSelectNode[] {
return (nodes || []).map((n) => ({
value: n.id,
label: n.deptName || '',
children: n.children?.length ? buildDeptTreeSelectData(n.children) : undefined
}))
}
const deptTreeSelectData = computed(() => buildDeptTreeSelectData(deptTreeForSelect.value))
const parentSelectId = computed({
get() {
const pid = (formData.parentId || '').trim()
return pid && pid !== '0' ? pid : undefined
},
set(val: string | undefined) {
formData.parentId = val ? val : '0'
}
})
function formatDeptPathLabel(nodeId: string | null | undefined) {
const id = String(nodeId ?? '').trim()
if (!id) return ''
return (
buildTreePathLabelFromFlat(id, deptFlatList.value, { nameKey: 'deptName' }) ||
deptFlatList.value.find((d) => d.id === id)?.deptName ||
''
)
}
function getParentDeptDisplay(parentId: string | null | undefined) {
const pid = (parentId || '').trim()
if (!pid || pid === '0') return '无(顶级组织)'
return formatDeptPathLabel(pid) || pid
}
/** 负责人:关联 sys_user,提交仍写 leader / leaderPhone 字符串 */
const leaderUserId = ref<string | null>(null)
const leaderUserOptions = ref<Api.User.UserItem[]>([])
const leaderSelectedSnapshot = ref<Api.User.UserItem | null>(null)
const leaderUserLoading = ref(false)
function formatLeaderUserLabel(u: Api.User.UserItem) {
const name = (u.nickName || '').trim() || '-'
const no = (u.userNo || '').trim()
const phone = (u.phoneNo || '').trim()
const parts = [name]
if (no) parts.push(no)
if (phone) parts.push(phone)
return parts.join(' · ')
}
async function remoteSearchLeaderUsers(query: string) {
leaderUserLoading.value = true
try {
const q = (query || '').trim()
const res = await fetchUserList({
page: 1,
pageSize: 50,
keyword: q || undefined,
status: '1'
})
let list = [...(res.list || [])]
const curId = leaderUserId.value
if (
curId &&
leaderSelectedSnapshot.value?.id === curId &&
!list.some((x) => x.id === curId)
) {
list = [leaderSelectedSnapshot.value, ...list]
}
leaderUserOptions.value = list
} catch (e) {
console.error('检索用户失败:', e)
leaderUserOptions.value = []
} finally {
leaderUserLoading.value = false
}
}
function onLeaderUserChange(val: string | null | undefined) {
if (!val) {
leaderUserId.value = null
leaderSelectedSnapshot.value = null
leaderUserOptions.value = []
formData.leader = ''
formData.leaderPhone = ''
return
}
const u =
leaderUserOptions.value.find((x) => x.id === val) ||
(leaderSelectedSnapshot.value?.id === val ? leaderSelectedSnapshot.value : undefined)
if (!u) return
leaderSelectedSnapshot.value = { ...u }
formData.leader = (u.nickName || '').trim()
formData.leaderPhone = (u.phoneNo || '').trim()
}
async function resolveLeaderUserForEdit() {
leaderUserId.value = null
leaderSelectedSnapshot.value = null
leaderUserOptions.value = []
const name = (formData.leader || '').trim()
const phone = (formData.leaderPhone || '').trim()
if (!name && !phone) return
leaderUserLoading.value = true
try {
const res = await fetchUserList({
page: 1,
pageSize: 50,
keyword: name || phone || undefined,
status: '1'
})
const list = res.list || []
const match =
list.find(
(u) =>
(u.nickName || '').trim() === name && (!phone || (u.phoneNo || '').trim() === phone)
) ||
(name ? list.find((u) => (u.nickName || '').trim() === name) : undefined) ||
(name ? list.find((u) => (u.userNo || '').trim() === name) : undefined)
if (match) {
leaderUserOptions.value = list
leaderUserId.value = match.id
leaderSelectedSnapshot.value = { ...match }
formData.leader = (match.nickName || '').trim()
if ((match.phoneNo || '').trim()) {
formData.leaderPhone = (match.phoneNo || '').trim()
}
}
} catch (e) {
console.error('匹配负责人用户失败:', e)
} finally {
leaderUserLoading.value = false
}
}
const loadDictOptions = async () => {
try {
const [zzlbbm, bmzt] = await Promise.all([
fetchDictDataOptions('zzlbbm'),
fetchDictDataOptions('bmzt')
])
deptCategoryOptions.value = zzlbbm || []
statusOptions.value = bmzt || []
} catch (error) {
console.error('加载字典选项失败:', error)
}
}
const getDeptCategoryLabel = (code: string) => {
if (!code) return '-'
const opt = deptCategoryOptions.value.find((o) => o.value === code)
return opt ? opt.label : code
}
const viewSections = computed(() => [
{
title: '基本信息',
fieldLayout: 'table' as const,
tableMode: 'triple' as const,
fields: [
{ label: '组织名称', value: viewRow.value?.deptName, long: false },
{ label: '上级组织', value: getParentDeptDisplay(viewRow.value?.parentId), long: false },
{
label: '组织类别',
value: getDeptCategoryLabel(viewRow.value?.deptCategoryCode || ''),
long: false
},
{
label: '状态',
value: viewRow.value?.status === '1' ? '正常' : '停用',
tagType: viewRow.value?.status === '1' ? 'success' : 'info',
long: false
},
{ label: '负责人', value: viewRow.value?.leader, long: false },
{ label: '联系电话', value: viewRow.value?.leaderPhone, long: false },
{ label: '备注', value: viewRow.value?.remark, long: false }
]
}
])
function handleView(row: Api.Dept.DeptItem) {
viewRow.value = { ...row }
viewVisible.value = true
}
const handleSelectionChange = (rows: Api.Dept.DeptItem[]) => {
selectedRows.value = rows
}
/** 树表格的 default-expand-all 只在初次挂载生效,展开态需用表格 API 同步 */
function applyTreeExpand(expand: boolean) {
const table = tableRef.value
if (!table?.toggleRowExpansion) return
const preorder = (items: Api.Dept.DeptItem[]) => {
for (const row of items) {
if (row.children?.length) {
table.toggleRowExpansion(row, expand)
preorder(row.children)
}
}
}
const postorder = (items: Api.Dept.DeptItem[]) => {
for (const row of items) {
if (row.children?.length) {
postorder(row.children)
table.toggleRowExpansion(row, expand)
}
}
}
if (expand) preorder(tableData.value)
else postorder(tableData.value)
}
async function loadDeptTreeForSelect() {
try {
const res = await fetchDeptTree()
deptTreeForSelect.value = res || []
deptFlatList.value = flattenDeptTree(deptTreeForSelect.value)
} catch (error) {
console.error('加载上级组织选项失败:', error)
deptTreeForSelect.value = []
deptFlatList.value = []
}
}
async function loadData() {
loading.value = true
try {
const res = await fetchDeptTree({
keyword: searchForm.keyword || undefined
})
tableData.value = res || []
await nextTick()
applyTreeExpand(isExpandAll.value)
} catch (error) {
console.error('加载组织数据失败:', error)
} finally {
loading.value = false
}
}
function handleSearch() {
loadData()
}
function handleReset() {
searchForm.keyword = ''
loadData()
}
function toggleExpand() {
isExpandAll.value = !isExpandAll.value
nextTick(() => {
applyTreeExpand(isExpandAll.value)
})
}
function resetForm() {
formData.parentId = ''
formData.deptName = ''
formData.deptCategoryCode = ''
formData.status = '1'
formData.leader = ''
formData.leaderPhone = ''
formData.orderNum = 0
formData.remark = ''
editingId.value = null
leaderUserId.value = null
leaderUserOptions.value = []
leaderSelectedSnapshot.value = null
}
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增组织'
resetForm()
formData.parentId = '0'
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
function handleAddChild(row: Api.Dept.DeptItem) {
isEdit.value = false
dialogTitle.value = '新增子组织'
resetForm()
formData.parentId = row.id
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
function handleEdit(row: Api.Dept.DeptItem) {
isEdit.value = true
dialogTitle.value = '编辑组织'
editingId.value = row.id
formData.parentId = row.parentId || ''
formData.deptName = row.deptName || ''
formData.deptCategoryCode = row.deptCategoryCode || ''
formData.status = row.status || '1'
formData.leader = row.leader || ''
formData.leaderPhone = row.leaderPhone || ''
formData.orderNum = row.orderNum ?? 0
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
nextTick(() => {
resolveLeaderUserForEdit()
})
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const leader = (formData.leader ?? '').trim()
const leaderPhone = (formData.leaderPhone ?? '').trim()
const remark = (formData.remark ?? '').trim()
if (isEdit.value && editingId.value) {
// 编辑时须显式传空字符串,否则 undefined 不会进入请求体,后端无法清空字段
await fetchUpdateDept(editingId.value, {
deptName: formData.deptName.trim(),
deptCategoryCode: formData.deptCategoryCode || '',
status: formData.status,
leader,
leaderPhone,
orderNum: formData.orderNum,
remark
})
} else {
await fetchCreateDept({
parentId: formData.parentId || undefined,
deptName: formData.deptName.trim(),
deptCategoryCode: formData.deptCategoryCode || undefined,
status: formData.status,
leader: leader || undefined,
leaderPhone: leaderPhone || undefined,
orderNum: formData.orderNum,
remark: remark || undefined
})
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await Promise.all([loadData(), loadDeptTreeForSelect()])
} catch {
/* 校验或接口错误已由全局处理 */
} finally {
submitLoading.value = false
}
}
function handleDropdownCommand(command: string, row: any) {
switch (command) {
case 'view':
handleView(row)
break
case 'edit':
handleEdit(row)
break
case 'delete':
handleDelete(row)
break
}
}
async function handleDelete(row: Api.Dept.DeptItem) {
try {
const childCount = row.children?.length || 0
const confirmText = childCount
? `该组织下有 ${childCount} 个子组织,删除将一并删除全部子组织。确定继续吗?`
: '确定要删除该组织吗?'
await ElMessageBox.confirm(confirmText, '提示', { type: 'warning' })
await fetchDeleteDept(row.id)
ElMessage.success('删除成功')
await Promise.all([loadData(), loadDeptTreeForSelect()])
} catch (error: any) {
if (error !== 'cancel') console.error('删除失败:', error)
}
}
function handleDialogClose() {
resetForm()
formRef.value?.clearValidate()
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadData()
loadDeptTreeForSelect()
loadDictOptions()
})
</script>
<style scoped lang="scss">
.dept-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>