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

893 lines
27 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.
<!-- 职位管理 -->
<!-- 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"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="postName"
label="职位名称"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="postCode"
label="职位编码"
min-width="120"
align="center"
header-align="center"
/>
<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="createTime"
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="handlePostBindUsers(row)">
<el-icon><User /></el-icon>
关联用户
</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 class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="post-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="职位名称" prop="postName">
<el-input v-model="formData.postName" placeholder="请输入职位名称" />
</el-form-item>
<el-form-item label="职位编码" prop="postCode">
<el-input v-model="formData.postCode" placeholder="请输入职位编码" />
</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="备注" 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>
<!-- 关联用户(布局对齐角色管理「数据权限」自定义范围) -->
<el-dialog
v-model="postBindDialogVisible"
title="关联用户"
width="1120px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<DialogFormBody :loading="postBindLoading" body-class="post-bind-dialog-body">
<p v-if="postBindPostName" class="post-bind-subtitle">
职位:<strong>{{ postBindPostName }}</strong>
</p>
<el-form label-position="top" class="data-scope-form">
<el-form-item label="绑定用户" class="data-scope-custom-form-item">
<div class="data-scope-custom-grid">
<div class="data-scope-custom-col">
<div>
<div class="data-scope-col-title">选择部门</div>
<div class="data-scope-col-tip">点击部门名称可按该部门及子部门筛选右侧用户</div>
</div>
<div class="data-scope-tree-wrap">
<el-tree
ref="postBindDeptTreeRef"
:data="postBindDeptTreeData"
highlight-current
node-key="id"
:props="{ label: 'deptName', children: 'children' }"
default-expand-all
:expand-on-click-node="false"
@node-click="handlePostBindDeptNodeClick"
/>
</div>
</div>
<div class="data-scope-custom-col">
<div class="data-scope-col-head-row">
<span class="data-scope-col-title">选择用户</span>
<el-button
v-if="postBindUserFilterDeptId"
class="btn-ghost"
size="small"
@click="clearPostBindUserDeptFilter"
>
清除部门筛选
</el-button>
</div>
<el-input
v-model="postBindUserKeyword"
class="data-scope-user-filter"
clearable
placeholder="按姓名、账号过滤"
/>
<div class="data-scope-user-list-wrap">
<el-checkbox-group
v-if="postBindFilteredUsers.length > 0"
v-model="postBindUserCheckedKeys"
>
<div
v-for="u in postBindFilteredUsers"
:key="u.id"
class="data-scope-user-row"
>
<el-checkbox :value="u.id">{{ postBindUserOptionLabel(u) }}</el-checkbox>
</div>
</el-checkbox-group>
<el-empty v-else :description="postBindUserEmptyDesc" :image-size="40" />
</div>
</div>
</div>
<div class="data-scope-selection-summary">
<template v-if="postBindSelectedUserSummaries.length > 0">
<div class="data-scope-summary-block">
<div class="data-scope-summary-heading">已选用户</div>
<ul class="data-scope-summary-list">
<li v-for="row in postBindSelectedUserSummaries" :key="'u-' + row.id">
{{ row.label }}
</li>
</ul>
</div>
</template>
<p v-else class="data-scope-summary-empty">暂未勾选用户</p>
</div>
</el-form-item>
</el-form>
</DialogFormBody>
<template #footer>
<el-button class="btn-ghost" @click="postBindDialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button
class="btn-main"
type="primary"
@click="handlePostBindSubmit"
:loading="postBindSubmitLoading"
>
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.postName || '职位详情'"
:icon="Briefcase"
:meta="`职位编码:${viewRow?.postCode || ''}${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,
User,
View,
Edit,
Delete,
More,
Briefcase,
RefreshLeft,
Search
} from '@element-plus/icons-vue'
import DialogFormBody from '@/components/business/dialog-form-body/index.vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import { useDictStore } from '@/store/modules/dict'
import {
fetchPostList,
fetchCreatePost,
fetchUpdatePost,
fetchDeletePost,
fetchDictDataOptions,
fetchDeptTree,
fetchUserList,
fetchPostBoundUserIds,
fetchUpdatePostBoundUsers
} from '@/api/admin'
defineOptions({ name: 'SystemPost' })
const dictStore = useDictStore()
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
const tableData = ref<Api.Post.PostItem[]>([])
const selectedRows = ref<Api.Post.PostItem[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
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 formData = reactive({
postName: '',
postCode: '',
status: '1',
orderNum: 0,
remark: ''
})
const formRules = {
postName: [{ required: true, message: '请输入职位名称', trigger: 'blur' }],
postCode: [{ required: true, message: '请输入职位编码', trigger: 'blur' }]
}
const statusOptions = ref<Api.Dict.DictDataOption[]>([])
// ---- 查看弹窗 ----
const viewVisible = ref(false)
const viewRow = ref<Api.Post.PostItem | null>(null)
const viewBoundUserNames = ref<string[]>([])
const viewSections = computed(() => {
const row = viewRow.value
if (!row) return []
const sections: any[] = [
{
title: '基本信息',
fieldLayout: 'table' as const,
tableMode: 'triple' as const,
fields: [
{ label: '职位名称', value: row.postName, long: false },
{ label: '职位编码', value: row.postCode, long: false },
{
label: '状态',
value: dictStore.getLabel('sys_status', row.status),
tagType: dictStore.getTagType('sys_status', row.status),
long: false
},
{ label: '创建时间', value: row.createTime, long: false },
{ label: '备注', value: row.remark, long: false }
]
}
]
if (viewBoundUserNames.value.length > 0) {
sections.push({
title: '关联用户',
fields: [{ label: '已关联用户', value: viewBoundUserNames.value.join('、'), span: true }]
})
}
return sections
})
async function handleView(row: Api.Post.PostItem) {
viewRow.value = row
viewBoundUserNames.value = []
viewVisible.value = true
try {
const [boundRes] = await Promise.all([fetchPostBoundUserIds(row.id), fetchDeptTree()])
const boundIds = boundRes?.userIds || []
if (boundIds.length === 0) return
const users = await fetchAllUsersForPostBind()
const idToName = new Map(users.map((u) => [u.id, u.nickName || u.userNo || u.id]))
viewBoundUserNames.value = boundIds.map((id: string) => idToName.get(id) || id)
} catch (e) {
console.error('加载关联用户失败:', e)
}
}
// ---- 关联用户弹窗(布局对齐角色管理「数据权限」自定义范围)----
const postBindDialogVisible = ref(false)
const postBindLoading = ref(false)
const postBindSubmitLoading = ref(false)
const postBindPostId = ref('')
const postBindPostName = ref('')
const postBindDeptTreeRef = ref()
const postBindDeptTreeData = ref<Api.Dept.DeptItem[]>([])
const postBindAllUsers = ref<
Array<{ id: string; nickName: string; userNo: string; deptId: string; deptName?: string }>
>([])
const postBindUserCheckedKeys = ref<string[]>([])
const postBindUserFilterDeptId = ref<string | null>(null)
const postBindUserKeyword = ref('')
function findPostBindDeptNodeById(
nodes: Api.Dept.DeptItem[],
id: string
): Api.Dept.DeptItem | null {
for (const n of nodes) {
if (n.id === id) return n
if (n.children?.length) {
const found = findPostBindDeptNodeById(n.children, id)
if (found) return found
}
}
return null
}
function collectPostBindDeptSubtreeIds(node: Api.Dept.DeptItem): string[] {
const ids = [node.id]
for (const c of node.children || []) ids.push(...collectPostBindDeptSubtreeIds(c))
return ids
}
function collectAllPostBindDeptIds(nodes: Api.Dept.DeptItem[]): string[] {
const ids: string[] = []
const walk = (list: Api.Dept.DeptItem[]) => {
for (const n of list) {
ids.push(n.id)
if (n.children?.length) walk(n.children)
}
}
walk(nodes)
return ids
}
async function expandPostBindDeptTree() {
await nextTick()
const tree = postBindDeptTreeRef.value
const keys = collectAllPostBindDeptIds(postBindDeptTreeData.value)
if (tree && keys.length && typeof tree.setExpandedKeys === 'function') {
tree.setExpandedKeys(keys)
}
}
const postBindDeptFilterIdSet = computed(() => {
const id = postBindUserFilterDeptId.value
if (!id) return null
const node = findPostBindDeptNodeById(postBindDeptTreeData.value, id)
if (!node) return new Set<string>([id])
return new Set(collectPostBindDeptSubtreeIds(node))
})
const postBindFilteredUsers = computed(() => {
const kw = postBindUserKeyword.value.trim().toLowerCase()
const deptSet = postBindDeptFilterIdSet.value
return postBindAllUsers.value.filter((u) => {
if (deptSet && !deptSet.has(u.deptId)) return false
if (!kw) return true
const name = (u.nickName || '').toLowerCase()
const no = (u.userNo || '').toLowerCase()
return name.includes(kw) || no.includes(kw)
})
})
const postBindUserEmptyDesc = computed(() => {
if (postBindAllUsers.value.length === 0) return '暂无用户数据'
const hasDept = Boolean(postBindUserFilterDeptId.value)
const kw = postBindUserKeyword.value.trim()
if (hasDept && kw) return '该部门下无匹配用户'
if (hasDept) return '该部门下暂无用户'
if (kw) return '无匹配用户'
return '无匹配用户'
})
const postBindSelectedUserSummaries = computed(() =>
postBindUserCheckedKeys.value.map((id) => {
const u = postBindAllUsers.value.find((x) => x.id === id)
return { id, label: u ? postBindUserOptionLabel(u) : id }
})
)
function postBindUserOptionLabel(u: {
id: string
nickName: string
userNo: string
deptName?: string
}) {
const base = u.nickName || u.userNo || u.id
if (u.deptName) return `${base}(${u.deptName})`
return base
}
function handlePostBindDeptNodeClick(data: Api.Dept.DeptItem) {
postBindUserFilterDeptId.value = data.id
postBindDeptTreeRef.value?.setCurrentKey?.(data.id)
}
function clearPostBindUserDeptFilter() {
postBindUserFilterDeptId.value = null
postBindDeptTreeRef.value?.setCurrentKey?.(null)
}
async function fetchAllUsersForPostBind(): Promise<
Array<{ id: string; nickName: string; userNo: string; deptId: string; deptName?: string }>
> {
const pageSize = MAX_ADMIN_LIST_PAGE_SIZE
const raw: any[] = []
let page = 1
for (;;) {
const res = await fetchUserList({ page, pageSize })
const list = res?.list || []
raw.push(...list)
if (list.length < pageSize) break
if (typeof res?.total === 'number' && raw.length >= res.total) break
page += 1
}
return raw.map((u: any) => ({
id: u.id,
deptId: u.deptId || '',
nickName: u.nickName || u.realName || '',
userNo: u.userNo || '',
deptName: u.deptName
}))
}
async function handlePostBindUsers(row: Api.Post.PostItem) {
postBindPostId.value = row.id
postBindPostName.value = row.postName || row.postCode || ''
postBindUserCheckedKeys.value = []
postBindUserFilterDeptId.value = null
postBindUserKeyword.value = ''
postBindDialogVisible.value = true
postBindLoading.value = true
try {
const [boundRes, deptTree, users] = await Promise.all([
fetchPostBoundUserIds(row.id),
fetchDeptTree(),
fetchAllUsersForPostBind()
])
postBindUserCheckedKeys.value = [...(boundRes?.userIds || [])]
postBindDeptTreeData.value = deptTree || []
postBindAllUsers.value = users
await expandPostBindDeptTree()
} finally {
postBindLoading.value = false
}
}
async function handlePostBindSubmit() {
postBindSubmitLoading.value = true
try {
await fetchUpdatePostBoundUsers(postBindPostId.value, [...postBindUserCheckedKeys.value])
postBindDialogVisible.value = false
} finally {
postBindSubmitLoading.value = false
}
}
const loadDictOptions = async () => {
try {
const bmzt = await fetchDictDataOptions('bmzt')
statusOptions.value = bmzt || []
} catch (error) {
console.error('加载字典选项失败:', error)
}
}
const handleSelectionChange = (rows: Api.Post.PostItem[]) => {
selectedRows.value = rows
}
async function loadData() {
loading.value = true
try {
const res = await fetchPostList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
pagination.page = 1
loadData()
}
function resetForm() {
formData.postName = ''
formData.postCode = ''
formData.status = '1'
formData.orderNum = 0
formData.remark = ''
editingId.value = null
}
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增职位'
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
function handleEdit(row: Api.Post.PostItem) {
isEdit.value = true
dialogTitle.value = '编辑职位'
editingId.value = row.id
formData.postName = row.postName || ''
formData.postCode = row.postCode || ''
formData.status = row.status || '1'
formData.orderNum = row.orderNum ?? 0
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const payload = {
postName: formData.postName.trim(),
postCode: formData.postCode.trim(),
status: formData.status,
orderNum: formData.orderNum,
remark: formData.remark?.trim() || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdatePost(editingId.value, payload)
} else {
await fetchCreatePost(payload)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await loadData()
} 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.Post.PostItem) {
await ElMessageBox.confirm(`确定删除职位「${row.postName}」吗?`, '提示', { type: 'warning' })
await fetchDeletePost(row.id)
ElMessage.success('删除成功')
await loadData()
}
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个职位吗?`, '提示', {
type: 'warning'
})
await withFullscreenLoading(async () => {
for (const id of ids) {
await fetchDeletePost(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadData()
loadDictOptions()
})
</script>
<style scoped lang="scss">
.post-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.post-bind-dialog-body {
min-height: 200px;
}
.post-bind-subtitle {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.5;
color: var(--el-text-color-regular);
}
.data-scope-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.data-scope-custom-form-item {
margin-top: 0;
:deep(.el-form-item__content) {
display: block;
}
}
.data-scope-custom-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
width: 100%;
}
.data-scope-custom-col {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.data-scope-col-title {
padding-left: 10px;
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-regular);
border-left: 3px solid #10b981;
}
.data-scope-col-tip {
margin-top: 4px;
font-size: 11px;
line-height: 1.45;
color: var(--mint-text-muted, var(--el-text-color-secondary));
}
.data-scope-col-head-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.data-scope-tree-wrap {
max-height: 280px;
padding: 8px;
overflow: auto;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.data-scope-user-filter {
width: 100%;
}
.data-scope-user-list-wrap {
max-height: 236px;
padding: 8px;
overflow: auto;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.data-scope-user-row {
padding: 4px 0;
}
.data-scope-selection-summary {
margin-top: 12px;
padding: 10px 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-blank);
}
.data-scope-summary-block + .data-scope-summary-block {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--el-border-color-lighter);
}
.data-scope-summary-heading {
margin-bottom: 6px;
padding-left: 10px;
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-regular);
border-left: 3px solid #10b981;
}
.data-scope-summary-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px 12px;
margin: 0;
padding: 0;
list-style: none;
font-size: 12px;
line-height: 1.45;
color: var(--el-text-color-primary);
}
.data-scope-summary-list li {
min-width: 0;
padding: 6px 8px;
overflow-wrap: anywhere;
word-break: break-word;
border-radius: 6px;
background: var(--el-fill-color-light);
}
.data-scope-summary-empty {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--mint-text-muted, var(--el-text-color-secondary));
}
</style>