<template>
|
<div class="page">
|
<el-form :inline="true" class="search-form" @submit.prevent="doSearch">
|
<el-form-item label="分组名称">
|
<el-input v-model="filters.groupName" placeholder="请输入分组名称" clearable style="width: 160px" />
|
</el-form-item>
|
<el-form-item label="负责人电话">
|
<el-input v-model="filters.leaderMobile" placeholder="请输入负责人电话" clearable style="width: 160px" />
|
</el-form-item>
|
<el-form-item>
|
<el-button type="primary" native-type="submit">搜索</el-button>
|
<el-button @click="resetSearch">重置</el-button>
|
</el-form-item>
|
<el-form-item class="push-right">
|
<el-button type="primary" @click="openCreate">新建分组</el-button>
|
</el-form-item>
|
</el-form>
|
|
<div class="table-wrap">
|
<el-table :data="rows" v-loading="loading" stripe height="100%">
|
<el-table-column label="序号" width="64" type="index" :index="indexMethod" />
|
<el-table-column prop="groupName" label="分组名称" min-width="140" show-overflow-tooltip />
|
<el-table-column prop="leaderName" label="负责人" width="110" show-overflow-tooltip />
|
<el-table-column prop="leaderMobileMasked" label="联系电话" width="140" />
|
<el-table-column prop="memberCount" label="绑定人员数量" width="120" />
|
<el-table-column label="创建时间" width="170">
|
<template #default="{ row }">{{ formatTime(row.createTime) }}</template>
|
</el-table-column>
|
<el-table-column label="操作" width="240" fixed="right">
|
<template #default="{ row }">
|
<div class="table-ops">
|
<el-button type="primary" link @click="goDetail(row)">详情</el-button>
|
<el-button type="primary" link @click="openEdit(row)">编辑</el-button>
|
<el-button type="primary" link @click="confirmDelete(row)">删除</el-button>
|
</div>
|
</template>
|
</el-table-column>
|
</el-table>
|
</div>
|
|
<el-pagination
|
class="main-pager"
|
background
|
layout="sizes, prev, pager, next"
|
:total="total"
|
:page-size="size"
|
:page-sizes="[10, 20, 50, 100]"
|
v-model:current-page="page"
|
@current-change="load"
|
@size-change="onMainSizeChange"
|
/>
|
|
<el-dialog v-model="dlg" :title="dlgTitle" width="860px" destroy-on-close class="group-dialog">
|
<el-form :model="form" label-position="top" class="group-form">
|
<div class="dialog-grid">
|
<el-form-item required>
|
<template #label>
|
<span class="req-label">分组名称</span>
|
</template>
|
<el-input v-model="form.groupName" maxlength="128" placeholder="请输入" />
|
</el-form-item>
|
|
<el-form-item required>
|
<template #label>
|
<span class="req-label">负责人</span>
|
</template>
|
<el-select
|
v-model="form.leaderMpUserId"
|
placeholder="请选择负责人"
|
class="fullw"
|
@visible-change="onLeaderVisibleChange"
|
>
|
<el-option
|
v-for="m in memberList"
|
:key="m.id"
|
:label="`${m.realName || ''}${m.mobileMasked ? ' - ' + m.mobileMasked : ''}`"
|
:value="m.id"
|
/>
|
</el-select>
|
</el-form-item>
|
</div>
|
|
<div class="dialog-grid dialog-grid-single">
|
<el-form-item required class="phone-item">
|
<template #label>
|
<span class="req-label">联系电话</span>
|
</template>
|
<el-input
|
:model-value="form.leaderMobile"
|
disabled
|
class="phone-input"
|
placeholder="请选择负责人"
|
/>
|
</el-form-item>
|
</div>
|
|
<div class="member-block">
|
<div class="member-head">
|
<div class="member-title">
|
<span class="req-label">关联人员</span>
|
</div>
|
<div class="member-actions">
|
<el-button @click="resetMembers">重置</el-button>
|
<el-button type="primary" @click="openPicker">添加</el-button>
|
</div>
|
</div>
|
|
<el-table :data="memberList" stripe border :max-height="memberTableMaxHeight">
|
<el-table-column label="序号" width="64" type="index" />
|
<el-table-column prop="realName" label="姓名" width="120" show-overflow-tooltip />
|
<el-table-column prop="staffNo" label="编号" width="140" show-overflow-tooltip />
|
<el-table-column prop="deptName" label="所属部门" min-width="160" show-overflow-tooltip />
|
<el-table-column label="操作" width="100" fixed="right">
|
<template #default="{ row }">
|
<div class="table-ops">
|
<el-tooltip v-if="row.id === form.leaderMpUserId" content="需先更换负责人后再移除" placement="top">
|
<span class="btn-wrap">
|
<el-button type="primary" link disabled>移除</el-button>
|
</span>
|
</el-tooltip>
|
<el-button v-else type="primary" link @click="removeMember(row.id)">移除</el-button>
|
</div>
|
</template>
|
</el-table-column>
|
</el-table>
|
</div>
|
</el-form>
|
|
<template #footer>
|
<div class="dialog-footer">
|
<el-button @click="dlg = false">取消</el-button>
|
<el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
|
</div>
|
</template>
|
</el-dialog>
|
|
<el-dialog v-model="picker" title="选择成员" width="960px" destroy-on-close>
|
<div class="picker-toolbar">
|
<el-form :inline="true" @submit.prevent="pickerSearch">
|
<el-form-item label="姓名">
|
<el-input v-model="pickerFilters.realName" placeholder="请输入姓名" clearable style="width: 140px" />
|
</el-form-item>
|
<el-form-item label="联系电话">
|
<el-input v-model="pickerFilters.mobile" placeholder="请输入联系电话" clearable style="width: 140px" />
|
</el-form-item>
|
<el-form-item label="部门">
|
<el-tree-select
|
v-model="pickerFilters.deptId"
|
:data="deptOptions"
|
:props="{ value: 'id', label: 'label', children: 'children' }"
|
check-strictly
|
:render-after-expand="false"
|
placeholder="全部"
|
clearable
|
style="width: 240px"
|
/>
|
</el-form-item>
|
<el-form-item>
|
<el-button type="primary" native-type="submit">搜索</el-button>
|
<el-button @click="pickerReset">重置</el-button>
|
</el-form-item>
|
</el-form>
|
</div>
|
|
<el-table
|
ref="pickerTableRef"
|
:data="pickerRows"
|
row-key="id"
|
v-loading="pickerLoading"
|
stripe
|
border
|
@selection-change="onPickerSelectionChange"
|
>
|
<el-table-column type="selection" width="48" :selectable="pickerRowSelectable" :reserve-selection="true" />
|
<el-table-column prop="realName" label="姓名" width="120" show-overflow-tooltip />
|
<el-table-column prop="staffNo" label="编号" width="140" show-overflow-tooltip />
|
<el-table-column prop="mobileMasked" label="联系电话" width="140" />
|
<el-table-column prop="deptName" label="所属部门" min-width="160" show-overflow-tooltip />
|
</el-table>
|
|
<el-pagination
|
class="pager"
|
background
|
layout="sizes, prev, pager, next"
|
:total="pickerTotal"
|
:page-size="pickerSize"
|
:page-sizes="[10, 20, 50, 100]"
|
v-model:current-page="pickerPage"
|
@current-change="loadPicker"
|
@size-change="onPickerSizeChange"
|
/>
|
|
<template #footer>
|
<div class="picker-footer">
|
<el-button @click="closePicker">取消</el-button>
|
<el-button @click="clearPickerSelection">清空勾选</el-button>
|
<el-button type="primary" @click="savePicker">保存</el-button>
|
</div>
|
</template>
|
</el-dialog>
|
</div>
|
</template>
|
|
<script setup lang="ts">
|
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
import { useRouter } from "vue-router";
|
import http from "../api/http";
|
|
type GroupRow = {
|
id: number;
|
uuid: string;
|
groupName: string;
|
leaderName?: string;
|
leaderMobileMasked?: string;
|
memberCount: number;
|
createTime: string;
|
};
|
|
type DeptTree = { id: number; label: string; children?: DeptTree[] };
|
|
type StaffRow = {
|
id: number;
|
uuid: string;
|
realName?: string;
|
staffNo?: string;
|
mobileMasked: string;
|
/** 明文手机号(详情/员工详情接口拉取后缓存) */
|
mobile?: string;
|
deptId?: number;
|
deptName?: string;
|
};
|
|
const router = useRouter();
|
|
const loading = ref(false);
|
const saving = ref(false);
|
const rows = ref<GroupRow[]>([]);
|
const page = ref(1);
|
const size = ref(10);
|
const total = ref(0);
|
|
const filters = reactive({
|
groupName: "",
|
leaderMobile: "",
|
});
|
|
function indexMethod(i: number) {
|
return (page.value - 1) * size.value + i + 1;
|
}
|
|
function formatTime(s: string) {
|
if (!s) return "";
|
return s.replace("T", " ").slice(0, 19);
|
}
|
|
function buildParams() {
|
return {
|
page: page.value,
|
size: size.value,
|
groupName: filters.groupName?.trim() || undefined,
|
leaderMobile: filters.leaderMobile?.trim() || undefined,
|
};
|
}
|
|
async function load() {
|
loading.value = true;
|
try {
|
const { data } = await http.get("/sys/staff-groups", { params: buildParams() });
|
rows.value = (data.result || []) as GroupRow[];
|
total.value = Number(data.total) || 0;
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "加载失败");
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
function doSearch() {
|
page.value = 1;
|
load();
|
}
|
|
function resetSearch() {
|
filters.groupName = "";
|
filters.leaderMobile = "";
|
page.value = 1;
|
load();
|
}
|
|
function onMainSizeChange(nextSize: number) {
|
size.value = nextSize;
|
page.value = 1;
|
load();
|
}
|
|
function goDetail(r: GroupRow) {
|
router.push({ name: "staff-group-detail", params: { id: r.id }, query: { uuid: r.uuid } });
|
}
|
|
// --- form dialog ---
|
const dlg = ref(false);
|
const dlgTitle = ref("新建分组");
|
const editing = ref<{ id: number; uuid: string } | null>(null);
|
|
const form = reactive({
|
uuid: "",
|
groupName: "",
|
leaderMpUserId: undefined as number | undefined,
|
leaderMobile: "",
|
});
|
|
const membersMap = ref(new Map<number, StaffRow>());
|
const memberList = computed(() => Array.from(membersMap.value.values()));
|
const memberTableMaxHeight = computed(() => (memberList.value.length > 20 ? 560 : undefined));
|
|
async function refreshLeaderMobile() {
|
const id = form.leaderMpUserId;
|
if (!id) {
|
form.leaderMobile = "";
|
return;
|
}
|
const m = membersMap.value.get(id);
|
if (m?.mobile) {
|
form.leaderMobile = m.mobile;
|
return;
|
}
|
if (!m?.uuid) {
|
form.leaderMobile = "";
|
return;
|
}
|
try {
|
const { data } = await http.get(`/sys/staff/${id}`, { params: { uuid: m.uuid } });
|
const mobile = (data.result?.mobile as string) || "";
|
form.leaderMobile = mobile;
|
membersMap.value.set(id, { ...m, mobile });
|
} catch {
|
form.leaderMobile = "";
|
}
|
}
|
|
watch(
|
() => form.leaderMpUserId,
|
() => {
|
void refreshLeaderMobile();
|
}
|
);
|
|
function openCreate() {
|
editing.value = null;
|
dlgTitle.value = "新建分组";
|
form.uuid = "";
|
form.groupName = "";
|
form.leaderMpUserId = undefined;
|
form.leaderMobile = "";
|
membersMap.value = new Map();
|
dlg.value = true;
|
}
|
|
async function openEdit(r: GroupRow) {
|
editing.value = { id: r.id, uuid: r.uuid };
|
dlgTitle.value = "编辑分组";
|
await loadDetailIntoForm(r.id, r.uuid);
|
dlg.value = true;
|
}
|
|
async function loadDetailIntoForm(id: number, uuid: string) {
|
const { data } = await http.get(`/sys/staff-groups/${id}`, { params: { uuid } });
|
const d = data.result as {
|
id: number;
|
uuid: string;
|
groupName: string;
|
leaderMpUserId?: number | null;
|
leaderMobile?: string;
|
members: { id: number; uuid: string; realName: string; staffNo?: string; deptId?: number; deptName?: string }[];
|
};
|
form.uuid = d.uuid;
|
form.groupName = d.groupName || "";
|
form.leaderMpUserId = d.leaderMpUserId ?? undefined;
|
form.leaderMobile = d.leaderMobile || "";
|
const map = new Map<number, StaffRow>();
|
for (const m of d.members || []) {
|
map.set(m.id, {
|
id: m.id,
|
uuid: m.uuid,
|
realName: m.realName,
|
staffNo: m.staffNo,
|
mobileMasked: "",
|
deptId: m.deptId,
|
deptName: m.deptName,
|
});
|
}
|
membersMap.value = map;
|
if (form.leaderMpUserId != null && form.leaderMobile) {
|
const lm = membersMap.value.get(form.leaderMpUserId);
|
if (lm) {
|
lm.mobile = form.leaderMobile;
|
membersMap.value.set(form.leaderMpUserId, lm);
|
}
|
} else {
|
await refreshLeaderMobile();
|
}
|
}
|
|
function resetMembers() {
|
if (!editing.value) {
|
// 新增:保留组名,其它清空
|
form.leaderMpUserId = undefined;
|
form.leaderMobile = "";
|
membersMap.value = new Map();
|
return;
|
}
|
// 编辑:重新拉取详情
|
loadDetailIntoForm(editing.value.id, editing.value.uuid);
|
}
|
|
function pickerRowSelectable(row: StaffRow) {
|
const lid = form.leaderMpUserId;
|
if (lid == null) return true;
|
return row.id !== lid;
|
}
|
|
function removeMember(id: number) {
|
if (id === form.leaderMpUserId) {
|
ElMessage.warning("需先更换负责人后再移除该成员");
|
return;
|
}
|
membersMap.value.delete(id);
|
}
|
|
function onLeaderVisibleChange(visible: boolean) {
|
if (!visible) {
|
return;
|
}
|
if (memberList.value.length > 0) {
|
return;
|
}
|
ElMessage.warning("请先选择人员,再从已有人员列表中选择负责人");
|
}
|
|
async function submitForm() {
|
if (!form.groupName.trim()) {
|
ElMessage.warning("请填写分组名称");
|
return;
|
}
|
const memberIds = Array.from(membersMap.value.keys());
|
if (memberIds.length === 0) {
|
ElMessage.warning("请至少添加一名关联人员");
|
return;
|
}
|
if (form.leaderMpUserId == null) {
|
ElMessage.warning("请选择负责人");
|
return;
|
}
|
if (!form.leaderMobile?.trim()) {
|
ElMessage.warning("负责人联系电话不能为空,请重新选择负责人或先添加该成员");
|
return;
|
}
|
saving.value = true;
|
try {
|
if (!editing.value) {
|
await http.post("/sys/staff-groups", {
|
groupName: form.groupName.trim(),
|
leaderMpUserId: form.leaderMpUserId,
|
memberIds,
|
});
|
ElMessage.success("已创建");
|
} else {
|
await http.put(`/sys/staff-groups/${editing.value.id}`, {
|
uuid: form.uuid,
|
groupName: form.groupName.trim(),
|
leaderMpUserId: form.leaderMpUserId,
|
memberIds,
|
});
|
ElMessage.success("已保存");
|
}
|
dlg.value = false;
|
load();
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "保存失败");
|
} finally {
|
saving.value = false;
|
}
|
}
|
|
function confirmDelete(r: GroupRow) {
|
ElMessageBox.confirm(`确认删除分组「${r.groupName}」?`, "删除确认", { type: "warning" })
|
.then(async () => {
|
try {
|
await http.delete(`/sys/staff-groups/${r.id}`, { params: { uuid: r.uuid } });
|
ElMessage.success("已删除");
|
load();
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "删除失败");
|
}
|
})
|
.catch(() => {});
|
}
|
|
// --- picker dialog ---
|
const picker = ref(false);
|
const pickerLoading = ref(false);
|
const pickerRows = ref<StaffRow[]>([]);
|
const pickerPage = ref(1);
|
const pickerSize = ref(10);
|
const pickerTotal = ref(0);
|
const pickerTableRef = ref();
|
|
const pickerFilters = reactive({
|
realName: "",
|
mobile: "",
|
deptId: undefined as number | undefined,
|
});
|
|
const pickedMap = ref(new Map<number, StaffRow>());
|
const restoringPickerSelection = ref(false);
|
|
const deptOptions = ref<DeptTree[]>([]);
|
|
function mapDeptSelect(nodes: Record<string, unknown>[]): DeptTree[] {
|
return nodes.map((n) => ({
|
id: n.id as number,
|
label: String(n.deptName),
|
children: n.children ? mapDeptSelect(n.children as Record<string, unknown>[]) : undefined,
|
}));
|
}
|
|
async function loadDeptTree() {
|
const { data } = await http.get("/sys/dept-manage/tree");
|
const raw = (data.result || []) as Record<string, unknown>[];
|
deptOptions.value = mapDeptSelect(raw);
|
}
|
|
function openPicker() {
|
picker.value = true;
|
pickedMap.value = new Map(membersMap.value);
|
pickerPage.value = 1;
|
loadPicker();
|
}
|
|
function pickerSearch() {
|
pickerPage.value = 1;
|
loadPicker();
|
}
|
|
function pickerReset() {
|
pickerFilters.realName = "";
|
pickerFilters.mobile = "";
|
pickerFilters.deptId = undefined;
|
pickerPage.value = 1;
|
loadPicker();
|
}
|
|
function onPickerSizeChange(nextSize: number) {
|
pickerSize.value = nextSize;
|
pickerPage.value = 1;
|
loadPicker();
|
}
|
|
function pickerParams() {
|
return {
|
page: pickerPage.value,
|
size: pickerSize.value,
|
deptId: pickerFilters.deptId,
|
realName: pickerFilters.realName?.trim() || undefined,
|
mobile: pickerFilters.mobile?.trim() || undefined,
|
status: 1,
|
};
|
}
|
|
async function loadPicker() {
|
pickerLoading.value = true;
|
try {
|
const { data } = await http.get("/sys/staff", { params: pickerParams() });
|
pickerRows.value = (data.result || []) as StaffRow[];
|
pickerTotal.value = Number(data.total) || 0;
|
await nextTick();
|
restoringPickerSelection.value = true;
|
pickerTableRef.value?.clearSelection?.();
|
for (const r of pickerRows.value) {
|
if (pickedMap.value.has(r.id)) {
|
pickerTableRef.value?.toggleRowSelection?.(r, true);
|
}
|
}
|
restoringPickerSelection.value = false;
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "加载失败");
|
} finally {
|
restoringPickerSelection.value = false;
|
pickerLoading.value = false;
|
}
|
}
|
|
function onPickerSelectionChange(cur: StaffRow[]) {
|
if (restoringPickerSelection.value) {
|
return;
|
}
|
const idsOnPage = new Set(pickerRows.value.map((x) => x.id));
|
const selectedOnPage = new Set(cur.map((x) => x.id));
|
for (const rid of idsOnPage) {
|
if (!selectedOnPage.has(rid)) {
|
pickedMap.value.delete(rid);
|
}
|
}
|
for (const r of cur) {
|
pickedMap.value.set(r.id, r);
|
}
|
}
|
|
function clearPickerSelection() {
|
pickedMap.value = new Map();
|
pickerTableRef.value?.clearSelection?.();
|
}
|
|
function closePicker() {
|
pickedMap.value = new Map(membersMap.value);
|
picker.value = false;
|
}
|
|
function savePicker() {
|
membersMap.value = new Map(pickedMap.value);
|
picker.value = false;
|
// leader must be in members
|
if (form.leaderMpUserId && !membersMap.value.has(form.leaderMpUserId)) {
|
form.leaderMpUserId = undefined;
|
form.leaderMobile = "";
|
}
|
void refreshLeaderMobile();
|
}
|
|
onMounted(async () => {
|
try {
|
await loadDeptTree();
|
} catch {
|
// ignore
|
}
|
load();
|
});
|
</script>
|
|
<style scoped>
|
.search-form {
|
margin-bottom: 12px;
|
flex: none;
|
}
|
.push-right {
|
margin-left: auto;
|
}
|
.page {
|
display: flex;
|
flex-direction: column;
|
height: calc(100vh - 190px);
|
min-height: 420px;
|
}
|
.table-wrap {
|
flex: 1;
|
min-height: 0;
|
}
|
.main-pager {
|
margin-top: 12px;
|
justify-content: flex-end;
|
flex: none;
|
}
|
.member-actions {
|
display: flex;
|
gap: 10px;
|
}
|
|
.group-form {
|
padding-top: 4px;
|
}
|
|
.dialog-grid {
|
display: grid;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
gap: 18px;
|
}
|
|
.dialog-grid-single {
|
grid-template-columns: minmax(0, 1fr);
|
}
|
|
.req-label {
|
color: #364867;
|
font-weight: 600;
|
}
|
|
.phone-item {
|
max-width: 240px;
|
}
|
|
.phone-input {
|
width: 100%;
|
}
|
|
.member-block {
|
margin-top: 8px;
|
}
|
|
.member-head {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
margin-bottom: 12px;
|
}
|
|
.member-title {
|
display: flex;
|
align-items: center;
|
}
|
|
.dialog-footer {
|
display: flex;
|
justify-content: flex-end;
|
gap: 12px;
|
width: 100%;
|
}
|
|
.picker-footer {
|
display: flex;
|
justify-content: flex-end;
|
gap: 10px;
|
}
|
.btn-wrap {
|
display: inline-block;
|
}
|
|
.fullw {
|
width: 100%;
|
}
|
</style>
|