| | |
| | | <el-form-item label="部门名称" required> |
| | | <el-input v-model="form.deptName" maxlength="128" placeholder="请输入" /> |
| | | </el-form-item> |
| | | <el-form-item label="部门负责人"> |
| | | <div class="leader-picker"> |
| | | <el-button @click="openLeaderPicker">选择负责人</el-button> |
| | | <el-button v-if="selectedLeaders.length" link type="primary" @click="clearLeaders">清空</el-button> |
| | | <div v-if="selectedLeaders.length" class="leader-tags"> |
| | | <el-tag |
| | | v-for="leader in selectedLeaders" |
| | | :key="leader.id" |
| | | closable |
| | | @close="removeLeader(leader.id)" |
| | | > |
| | | {{ leader.realName }} |
| | | </el-tag> |
| | | </div> |
| | | <div v-else class="leader-empty">未选择负责人</div> |
| | | </div> |
| | | </el-form-item> |
| | | <el-form-item label="部门状态" required> |
| | | <el-radio-group v-model="form.status"> |
| | | <el-radio :label="1">启用</el-radio> |
| | |
| | | </el-button> |
| | | </template> |
| | | </el-dialog> |
| | | |
| | | <el-dialog v-model="leaderDlg" title="选择部门负责人" width="760px" destroy-on-close @closed="resetLeaderDialog"> |
| | | <el-form class="leader-search" @submit.prevent="loadLeaderCandidates"> |
| | | <el-form-item label="姓名"> |
| | | <el-input v-model="leaderFilters.realName" placeholder="请输入姓名" clearable /> |
| | | </el-form-item> |
| | | <el-form-item label="联系方式"> |
| | | <el-input v-model="leaderFilters.mobile" placeholder="请输入联系方式" clearable /> |
| | | </el-form-item> |
| | | <el-form-item> |
| | | <el-button @click="resetLeaderSearch">重置</el-button> |
| | | <el-button type="primary" native-type="submit">搜索</el-button> |
| | | </el-form-item> |
| | | </el-form> |
| | | <el-table |
| | | ref="leaderTableRef" |
| | | :data="leaderRows" |
| | | v-loading="leaderLoading" |
| | | row-key="id" |
| | | border |
| | | height="360" |
| | | @selection-change="onLeaderSelectionChange" |
| | | > |
| | | <el-table-column type="selection" width="48" reserve-selection /> |
| | | <el-table-column prop="realName" label="姓名" min-width="120" show-overflow-tooltip /> |
| | | <el-table-column prop="mobileMasked" label="联系方式" width="140" /> |
| | | <el-table-column prop="deptPath" label="所属部门" min-width="220" show-overflow-tooltip /> |
| | | </el-table> |
| | | <div class="leader-pager"> |
| | | <div>已选 {{ leaderSelection.length }} 人</div> |
| | | <el-pagination |
| | | background |
| | | layout="sizes, prev, pager, next" |
| | | :total="leaderTotal" |
| | | :page-size="leaderSize" |
| | | :page-sizes="[10, 20, 50, 100]" |
| | | v-model:current-page="leaderPage" |
| | | @current-change="loadLeaderCandidates" |
| | | @size-change="onLeaderSizeChange" |
| | | /> |
| | | </div> |
| | | <template #footer> |
| | | <el-button @click="leaderDlg = false">取消</el-button> |
| | | <el-button type="primary" @click="confirmLeaders">确定</el-button> |
| | | </template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup lang="ts"> |
| | | import { computed, ref } from "vue"; |
| | | import { computed, nextTick, ref } from "vue"; |
| | | import { ElMessage, ElMessageBox } from "element-plus"; |
| | | import http from "../api/http"; |
| | | import { normalizeFormData } from "../utils/formHelper"; |
| | | |
| | | type DeptNode = { |
| | | id: number; |
| | |
| | | sortOrder?: number; |
| | | status: number; |
| | | level: number; |
| | | leaderIds?: number[]; |
| | | leaderNames?: string; |
| | | children?: DeptNode[]; |
| | | }; |
| | | |
| | |
| | | const parentDeptName = ref(""); |
| | | const childParentId = ref<number | null>(null); |
| | | const pendingExpandRowKey = ref<string | null>(null); |
| | | type StaffOption = { id: number; realName: string; deptPath: string; mobileMasked?: string }; |
| | | const staffOptions = ref<StaffOption[]>([]); |
| | | const selectedLeaders = ref<StaffOption[]>([]); |
| | | const leaderDlg = ref(false); |
| | | const leaderTableRef = ref(); |
| | | const leaderLoading = ref(false); |
| | | const leaderRows = ref<StaffOption[]>([]); |
| | | const leaderSelection = ref<StaffOption[]>([]); |
| | | const leaderPage = ref(1); |
| | | const leaderSize = ref(10); |
| | | const leaderTotal = ref(0); |
| | | const leaderFilters = ref({ realName: "", mobile: "" }); |
| | | |
| | | const form = ref({ |
| | | uuid: "", |
| | | deptName: "", |
| | | status: 1, |
| | | parentId: undefined as number | undefined, |
| | | leaderIds: [] as number[], |
| | | }); |
| | | |
| | | const dlgTitle = computed(() => { |
| | |
| | | expandedRowKeys.value = expandedRowKeys.value.filter(id => !collapsedKeys.has(id)); |
| | | } |
| | | |
| | | async function loadStaffOptionsByIds(ids: number[]) { |
| | | const missingIds = ids.filter(id => !staffOptions.value.some(s => s.id === id)); |
| | | if (!missingIds.length) return; |
| | | try { |
| | | const { data } = await http.get("/sys/staff", { params: { page: 1, size: 1000, status: 1 } }); |
| | | const staffList = (data.result || []) as { id: number; realName: string; deptPath?: string }[]; |
| | | staffOptions.value = staffList.map(s => ({ |
| | | id: s.id, |
| | | realName: s.realName, |
| | | deptPath: s.deptPath || '未分配部门', |
| | | })); |
| | | } catch (e: unknown) { |
| | | console.error("加载员工列表失败", e); |
| | | } |
| | | } |
| | | |
| | | function syncSelectedLeadersFromIds(ids: number[], namesText?: string) { |
| | | const names = (namesText || "").split("、").filter(Boolean); |
| | | selectedLeaders.value = ids.map((id, index) => { |
| | | const cached = staffOptions.value.find(s => s.id === id); |
| | | return cached || { id, realName: names[index] || `员工${id}`, deptPath: "" }; |
| | | }); |
| | | } |
| | | |
| | | function openAddRoot() { |
| | | mode.value = "root"; |
| | | editingId.value = null; |
| | | editingIsRoot.value = true; |
| | | parentDeptName.value = ""; |
| | | childParentId.value = null; |
| | | form.value = { uuid: "", deptName: "", status: 1, parentId: undefined }; |
| | | form.value = { uuid: "", deptName: "", status: 1, parentId: undefined, leaderIds: [] }; |
| | | selectedLeaders.value = []; |
| | | dlg.value = true; |
| | | } |
| | | |
| | |
| | | editingIsRoot.value = false; |
| | | parentDeptName.value = row.deptName; |
| | | childParentId.value = row.id; |
| | | form.value = { uuid: "", deptName: "", status: 1, parentId: row.id }; |
| | | form.value = { uuid: "", deptName: "", status: 1, parentId: row.id, leaderIds: [] }; |
| | | selectedLeaders.value = []; |
| | | dlg.value = true; |
| | | } |
| | | |
| | | function openEdit(row: DeptNode) { |
| | | async function openEdit(row: DeptNode) { |
| | | mode.value = "edit"; |
| | | editingId.value = row.id; |
| | | const root = !row.parentId || row.parentId === 0; |
| | | editingIsRoot.value = root; |
| | | parentDeptName.value = ""; |
| | | childParentId.value = null; |
| | | const leaderIds = Array.isArray(row.leaderIds) ? row.leaderIds : []; |
| | | await loadStaffOptionsByIds(leaderIds); |
| | | form.value = { |
| | | uuid: row.uuid, |
| | | deptName: row.deptName, |
| | | status: row.status, |
| | | parentId: root ? undefined : row.parentId, |
| | | leaderIds, |
| | | }; |
| | | syncSelectedLeadersFromIds(leaderIds, row.leaderNames); |
| | | if (!root) { |
| | | const disabled = new Set(collectDescendantIds(row)); |
| | | parentTreeSelectData.value = toTreeSelectData(treeData.value, disabled); |
| | |
| | | parentTreeSelectData.value = []; |
| | | } |
| | | |
| | | function openLeaderPicker() { |
| | | leaderSelection.value = [...selectedLeaders.value]; |
| | | leaderDlg.value = true; |
| | | loadLeaderCandidates(); |
| | | } |
| | | |
| | | async function loadLeaderCandidates() { |
| | | leaderLoading.value = true; |
| | | try { |
| | | const { data } = await http.get("/sys/staff", { |
| | | params: { |
| | | page: leaderPage.value, |
| | | size: leaderSize.value, |
| | | status: 1, |
| | | realName: leaderFilters.value.realName.trim() || undefined, |
| | | mobile: leaderFilters.value.mobile.trim() || undefined, |
| | | }, |
| | | }); |
| | | leaderRows.value = ((data.result || []) as StaffOption[]).map(s => ({ |
| | | ...s, |
| | | deptPath: s.deptPath || "未分配部门", |
| | | })); |
| | | leaderTotal.value = Number(data.total) || 0; |
| | | await nextTick(); |
| | | syncLeaderTableSelection(); |
| | | } catch (e: unknown) { |
| | | ElMessage.error(e instanceof Error ? e.message : "加载员工失败"); |
| | | } finally { |
| | | leaderLoading.value = false; |
| | | } |
| | | } |
| | | |
| | | function syncLeaderTableSelection() { |
| | | const selectedIds = new Set(leaderSelection.value.map(s => s.id)); |
| | | leaderRows.value.forEach(row => { |
| | | leaderTableRef.value?.toggleRowSelection(row, selectedIds.has(row.id)); |
| | | }); |
| | | } |
| | | |
| | | function onLeaderSelectionChange(selection: StaffOption[]) { |
| | | const currentPageIds = new Set(leaderRows.value.map(s => s.id)); |
| | | const kept = leaderSelection.value.filter(s => !currentPageIds.has(s.id)); |
| | | const merged = [...kept, ...selection]; |
| | | const seen = new Set<number>(); |
| | | leaderSelection.value = merged.filter(item => { |
| | | if (seen.has(item.id)) return false; |
| | | seen.add(item.id); |
| | | return true; |
| | | }); |
| | | } |
| | | |
| | | function resetLeaderSearch() { |
| | | leaderFilters.value = { realName: "", mobile: "" }; |
| | | leaderPage.value = 1; |
| | | loadLeaderCandidates(); |
| | | } |
| | | |
| | | function onLeaderSizeChange(v: number) { |
| | | leaderSize.value = v; |
| | | leaderPage.value = 1; |
| | | loadLeaderCandidates(); |
| | | } |
| | | |
| | | function confirmLeaders() { |
| | | selectedLeaders.value = [...leaderSelection.value]; |
| | | form.value.leaderIds = selectedLeaders.value.map(s => s.id); |
| | | leaderDlg.value = false; |
| | | } |
| | | |
| | | function resetLeaderDialog() { |
| | | leaderRows.value = []; |
| | | } |
| | | |
| | | function removeLeader(id: number) { |
| | | selectedLeaders.value = selectedLeaders.value.filter(s => s.id !== id); |
| | | form.value.leaderIds = selectedLeaders.value.map(s => s.id); |
| | | } |
| | | |
| | | function clearLeaders() { |
| | | selectedLeaders.value = []; |
| | | form.value.leaderIds = []; |
| | | } |
| | | |
| | | async function submitDlg() { |
| | | if (!form.value.deptName.trim()) { |
| | | ElMessage.warning("请填写部门名称"); |
| | |
| | | saving.value = true; |
| | | try { |
| | | if (mode.value === "root") { |
| | | await http.post("/sys/dept-manage", { |
| | | const payload = normalizeFormData({ |
| | | parentId: 0, |
| | | deptName: form.value.deptName.trim(), |
| | | status: form.value.status, |
| | | leaderIds: form.value.leaderIds, |
| | | }); |
| | | await http.post("/sys/dept-manage", payload); |
| | | ElMessage.success("已创建"); |
| | | } else if (mode.value === "child") { |
| | | if (childParentId.value == null) { |
| | |
| | | return; |
| | | } |
| | | pendingExpandRowKey.value = toRowKey(childParentId.value); |
| | | await http.post("/sys/dept-manage", { |
| | | const payload = normalizeFormData({ |
| | | parentId: childParentId.value, |
| | | deptName: form.value.deptName.trim(), |
| | | status: form.value.status, |
| | | leaderIds: form.value.leaderIds, |
| | | }); |
| | | await http.post("/sys/dept-manage", payload); |
| | | ElMessage.success("已创建"); |
| | | } else if (editingId.value != null) { |
| | | const body: Record<string, unknown> = { |
| | | uuid: form.value.uuid, |
| | | deptName: form.value.deptName.trim(), |
| | | status: form.value.status, |
| | | leaderIds: form.value.leaderIds, |
| | | }; |
| | | if (!editingIsRoot.value) { |
| | | if (form.value.parentId == null) { |
| | |
| | | } |
| | | body.parentId = form.value.parentId; |
| | | } |
| | | await http.put(`/sys/dept-manage/${editingId.value}`, body); |
| | | const payload = normalizeFormData(body); |
| | | await http.put(`/sys/dept-manage/${editingId.value}`, payload); |
| | | ElMessage.success("已保存"); |
| | | } |
| | | dlg.value = false; |
| | |
| | | width: 100%; |
| | | } |
| | | |
| | | .leader-picker { |
| | | width: 100%; |
| | | } |
| | | |
| | | .leader-tags { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 6px; |
| | | margin-top: 8px; |
| | | } |
| | | |
| | | .leader-empty { |
| | | color: #909399; |
| | | font-size: 13px; |
| | | margin-top: 8px; |
| | | } |
| | | |
| | | .leader-search { |
| | | display: grid; |
| | | grid-template-columns: 1fr 1fr auto; |
| | | gap: 12px; |
| | | } |
| | | |
| | | .leader-pager { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | margin-top: 12px; |
| | | } |
| | | |
| | | :deep(.table-ops .el-button) { |
| | | font-size: 14px; |
| | | } |