Archived
forked from admin/userbase
443 lines
16 KiB
Vue
443 lines
16 KiB
Vue
<template>
|
||
<div class="page-wrapper tree-wrapper group-user-page">
|
||
<Group class="tree-container" v-model:curTreeNode="selectedNode" :treeApi="treeApi" @on-change="onTreeChange"
|
||
@on-tree-data="onTreeData" />
|
||
<div class="main-container">
|
||
<div class="search-wrap">
|
||
<Button type="primary" @click="showAddUser">
|
||
<Icon type="md-add" style="margin-right: 4px" />新增用户
|
||
</Button>
|
||
<Button type="success" class="ml-15" @click="showImportUser">批量导入</Button>
|
||
<Button class="ml-15" @click="onExport">导出组用户</Button>
|
||
|
||
<Input placeholder="输入用户名 / 真实姓名搜索" class="fr" search clearable suffix="ios-search" style="width: 220px"
|
||
v-model="keyword" @on-search="onSearch" @on-clear="onClear" />
|
||
</div>
|
||
|
||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight">
|
||
<template #default="{ checkList }">
|
||
<Button type="primary" ghost :disabled="!checkList?.length" @click="openAdjust(checkList)">调整组</Button>
|
||
<Button type="warning" ghost class="ml-15" :disabled="!checkList?.length" @click="openBatchStatus(checkList)">设置状态</Button>
|
||
<Button type="error" ghost class="ml-15" :disabled="!checkList?.length" @click="openBatchDelete(checkList)">批量删除</Button>
|
||
</template>
|
||
</TablePageAlovaNew>
|
||
|
||
<!-- 容器组件:新增 / 编辑 / 调整组 / 批量导入 -->
|
||
<AddUserContainer ref="addRef" />
|
||
<EditUserContainer ref="editRef" />
|
||
<AdjustGroupContainer ref="adjustRef" />
|
||
<BatchImportUser v-model="importModal" :current-group-id="selectedNode?.id" @success="onImportSuccess" />
|
||
<UserSort v-model:visible="sortModal" :user-id="sortRow?.id ?? null"
|
||
:user-account="sortRow?.account || sortRow?.realName || ''"
|
||
:current-sort-no="sortRow?.sortNo ?? null" :group-id="selectedNode?.id ?? null" @saved="onSortSaved" />
|
||
|
||
<!-- 批量设置用户状态 -->
|
||
<Modal v-model="statusModal" title="设置用户的状态" :width="520" :mask-closable="false">
|
||
<div class="batch-status-body">
|
||
<div class="user-list-row">
|
||
<span class="user-list-label">需设置用户:</span>
|
||
<ul class="user-list">
|
||
<li v-for="item in statusSelectedRows" :key="item.id">{{ item.realName || item.account }}</li>
|
||
</ul>
|
||
</div>
|
||
<div class="status-radio-row">
|
||
<span class="status-label">选择设置状态:</span>
|
||
<RadioGroup v-model="statusValue" type="button">
|
||
<Radio :label="0">正常</Radio>
|
||
<Radio :label="1">禁用</Radio>
|
||
</RadioGroup>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<Button type="primary" :loading="statusSubmitting" @click="confirmBatchStatus">确定</Button>
|
||
<Button @click="statusModal = false">取消</Button>
|
||
</template>
|
||
</Modal>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { ref, computed } from "vue";
|
||
import { Message } from "view-ui-plus";
|
||
import { useDefaultStore } from "@/store";
|
||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||
import MyConfirm from "@/components/MyConfirm";
|
||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||
import { OperatorEnum } from '@/types';
|
||
import Group from './group/index.vue'
|
||
import AddUserContainer from './user/AddUserContainer.vue';
|
||
import EditUserContainer from './user/EditUserContainer.vue';
|
||
import AdjustGroupContainer from './AdjustGroupContainer.vue';
|
||
import BatchImportUser from './user/BatchImportUser.vue';
|
||
import UserSort from './user/UserSort.vue';
|
||
|
||
defineOptions({ name: "GroupUser" });
|
||
|
||
const { $utils, $conf } = useCurrentInstance();
|
||
|
||
const treeApi = () => Apis.SysGroup.getTree();
|
||
|
||
/* ================= 组织树 ================= */
|
||
const selectedNode = ref<any>({})
|
||
const treeData = ref<any[]>([])
|
||
|
||
function onTreeChange(node: any) {
|
||
selectedNode.value = node;
|
||
dataParams.value.groupId = node?.id ?? null;
|
||
dataParams.value.withSub = 0;
|
||
tableRef.value?.refreshData();
|
||
}
|
||
function onTreeData(tree: any[]) {
|
||
treeData.value = tree;
|
||
}
|
||
function onSearch() {
|
||
dataParams.value.name = keyword.value.trim() || undefined;
|
||
// 手动搜索:包含子组用户
|
||
dataParams.value.withSub = 1;
|
||
tableRef.value?.refreshData();
|
||
}
|
||
|
||
function onClear() {
|
||
keyword.value = '';
|
||
dataParams.value.name = undefined;
|
||
// 取消搜索:回到仅当前组浏览
|
||
dataParams.value.withSub = 0;
|
||
tableRef.value?.refreshData();
|
||
}
|
||
|
||
/* ================= 所在组下拉(拍平组织树) ================= */
|
||
const groupOptions = computed(() => {
|
||
const flat: { id: number; name: string }[] = [];
|
||
const walk = (list: any[], prefix = '') => {
|
||
list.forEach(n => {
|
||
flat.push({ id: n.id, name: prefix ? `${prefix} / ${n.name}` : n.name });
|
||
if (n.children?.length) walk(n.children, prefix ? `${prefix} / ${n.name}` : n.name);
|
||
});
|
||
};
|
||
walk(treeData.value);
|
||
return flat;
|
||
})
|
||
|
||
/* ================= 表格数据源 ================= */
|
||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||
const keyword = ref('')
|
||
const dataParams = ref<{
|
||
name: string | undefined;
|
||
groupId: number | null;
|
||
withSub: number;
|
||
}>({
|
||
name: undefined,
|
||
groupId: null,
|
||
// 默认仅当前组;仅手动搜索时才包含子组
|
||
withSub: 0,
|
||
});
|
||
|
||
const tableApi = (params: { pageIndex: number; pageSize: number }) => {
|
||
return Apis.SysUser.list({
|
||
params: {
|
||
name: dataParams.value.name,
|
||
groupId: dataParams.value.groupId ?? undefined,
|
||
// 默认仅当前组;手动搜索时置 1 才包含子组
|
||
withSub: dataParams.value.withSub,
|
||
pageIndex: params.pageIndex,
|
||
pageSize: params.pageSize,
|
||
},
|
||
cacheFor: null,
|
||
});
|
||
};
|
||
|
||
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }))
|
||
|
||
/* ================= 用户身份(roles[])展示 ================= */
|
||
// 唯一真源:后端 SysRoleEnum(经 GetSysDict.sysrole 下发),杜绝前端硬编码漂移。
|
||
// roles 中仅存放 SysRoleEnum 的 key(1=老师 … 7=管理员),不再混用 UserTypeEnum。
|
||
const SYS_ROLE_MAP_FALLBACK: Record<number, string> = {
|
||
1: '老师', 2: '校领导', 3: '学生', 4: '家长',
|
||
5: '局领导', 6: '工作号', 7: '管理员',
|
||
};
|
||
const store = useDefaultStore();
|
||
const userTypeMap = computed<Record<number, string>>(() => {
|
||
const sysrole = (store.getSysDict as any)?.sysrole;
|
||
if (Array.isArray(sysrole) && sysrole.length) {
|
||
const m: Record<number, string> = {};
|
||
sysrole.forEach((it: any) => { m[Number(it.k)] = it.v; });
|
||
return m;
|
||
}
|
||
return SYS_ROLE_MAP_FALLBACK;
|
||
});
|
||
function describeRoles(roles: any): string {
|
||
if (!Array.isArray(roles) || roles.length === 0) return '-';
|
||
const names = roles
|
||
.map((r: any) => userTypeMap.value[Number(r)])
|
||
.filter((n: string) => !!n);
|
||
return names.length ? names.join('/') : '-';
|
||
}
|
||
|
||
/* ================= 表格列 ================= */
|
||
const columns = [
|
||
{ type: 'selection', width: 55, align: 'center' },
|
||
{ type: 'index', title: '序号', width: 70, align: 'center' },
|
||
{ title: '用户名', key: 'account', minWidth: 120 },
|
||
{ title: '真实姓名', key: 'realName', minWidth: 120 },
|
||
{ title: '手机号', key: 'phone', minWidth: 140 },
|
||
{
|
||
title: '用户身份', key: 'roles', minWidth: 180,
|
||
render: (h: any, { row }: any) => h('span', describeRoles(row.roles)),
|
||
},
|
||
{
|
||
title: '状态', key: 'disabled', width: 130, align: 'center',
|
||
render: (h: any, { row }: any) => {
|
||
if (row.disabled) return h('Tag', { color: 'default' }, '禁用');
|
||
if (row.groupStatus === 1) {
|
||
// 正常 Tag + 转出徽标(view-ui-plus Badge status 模式:圆点 + 文本)
|
||
return h('div', { style: 'display:inline-flex;align-items:center;gap:6px' }, [
|
||
h('Tag', { color: 'success' }, '正常'),
|
||
h('Badge', { props: { status: 'warning', text: '转出' } }),
|
||
]);
|
||
}
|
||
return h('Tag', { color: 'success' }, '正常');
|
||
},
|
||
},
|
||
{
|
||
title: '排序号', key: 'sortNo', width: 90, align: 'center',
|
||
render: (h: any, { row }: any) => h('span', row.sortNo == null ? '-' : row.sortNo),
|
||
},
|
||
{
|
||
title: '操作', key: 'action', width: 220, align: 'center', fixed: 'right' as const,
|
||
render: (h: any, { row }: any) => {
|
||
const opList: any[] = [
|
||
h('a', {
|
||
style: { marginRight: '8px', color: '#577afb' },
|
||
onClick: () => handleEdit(row),
|
||
}, '编辑'),
|
||
];
|
||
// 仅「单组浏览」(withSub=0)时开放排序:此时每个用户都直接属于当前组,排序号语义明确;
|
||
// 含子组搜索(withSub=1)下用户可能属于子组,排序号归属不清,故隐藏。
|
||
if (dataParams.value.withSub === 0) {
|
||
opList.push(
|
||
h('a', {
|
||
style: { marginRight: '8px', color: '#2d8cf0' },
|
||
onClick: () => openSort(row),
|
||
}, '排序'),
|
||
);
|
||
}
|
||
opList.push(
|
||
h('a', {
|
||
style: { color: '#ed4014' },
|
||
onClick: () => handleDelete(row),
|
||
}, '删除'),
|
||
);
|
||
return h('div', opList);
|
||
},
|
||
},
|
||
];
|
||
|
||
/* ================= 导出组用户 ================= */
|
||
function onExport() {
|
||
const gid = selectedNode.value?.id ?? null;
|
||
if (gid == null) {
|
||
Message.warning('请先选择要导出的组');
|
||
return;
|
||
}
|
||
const params = new URLSearchParams();
|
||
params.set('groupId', String(gid));
|
||
params.set('withSub', '1');
|
||
if (dataParams.value.name) params.set('name', dataParams.value.name);
|
||
window.open(`${$conf.serviceHost}/api/sysuser/Export?${params.toString()}`);
|
||
}
|
||
|
||
/* ================= 批量导入 ================= */
|
||
const importModal = ref(false);
|
||
function showImportUser() {
|
||
importModal.value = true;
|
||
}
|
||
function onImportSuccess() {
|
||
tableRef.value?.reloadData(OperatorEnum.sel);
|
||
}
|
||
|
||
/* ================= 新增 ================= */
|
||
const addRef = ref();
|
||
function showAddUser() {
|
||
const curGroupId = selectedNode.value?.id ?? null;
|
||
addRef.value?.open(groupOptions.value, curGroupId, () => {
|
||
tableRef.value?.reloadData(OperatorEnum.add);
|
||
});
|
||
}
|
||
|
||
/* ================= 编辑 ================= */
|
||
const editRef = ref();
|
||
function handleEdit(row: any) {
|
||
// 当前点击所在的组:优先用选中节点 id,兜底用当前正在浏览的组(dataParams.groupId,
|
||
// 由 onTreeChange 设置,比 selectedNode 更可靠),供"所在组"只读展示与兜底
|
||
const curGroupId = selectedNode.value?.id ?? dataParams.value.groupId ?? null;
|
||
editRef.value?.open(row, groupOptions.value, curGroupId, () => {
|
||
tableRef.value?.reloadData(OperatorEnum.edit);
|
||
});
|
||
}
|
||
|
||
/* ================= 调整组 ================= */
|
||
const adjustRef = ref();
|
||
function openAdjust(checkList: number[]) {
|
||
if (!checkList?.length) {
|
||
Message.error('请选择待调整用户!');
|
||
return;
|
||
}
|
||
const rows = tableRef.value?.list.filter((r: any) => checkList.includes(r.id)) || [];
|
||
adjustRef.value?.open(checkList, rows, treeData.value, () => {
|
||
tableRef.value?.reloadData(OperatorEnum.sel);
|
||
});
|
||
}
|
||
|
||
/* ================= 删除(从当前组移除) ================= */
|
||
async function handleDelete(row: any) {
|
||
const gid = selectedNode.value?.id ?? null;
|
||
if (gid == null) {
|
||
Message.warning('请先选择组');
|
||
return;
|
||
}
|
||
await MyConfirm({
|
||
msg: `确定删除用户「${row.realName || row.account}」吗?`,
|
||
info: '删除后不可恢复!',
|
||
async onOk() {
|
||
try {
|
||
await Apis.SysUser.deleteFromGroup({ pathParams: { userId: row.id, groupId: gid } });
|
||
Message.success('删除成功!');
|
||
tableRef.value?.reloadData(OperatorEnum.del);
|
||
} catch (e: any) {
|
||
console.warn(e?.message || '删除失败');
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
/* ================= 批量删除 ================= */
|
||
const batchDeleteSelected = ref<number[]>([]);
|
||
async function openBatchDelete(checkList: number[]) {
|
||
if (!checkList?.length) {
|
||
Message.error('请选择待删除用户!');
|
||
return;
|
||
}
|
||
batchDeleteSelected.value = checkList;
|
||
const rows = tableRef.value?.list.filter((r: any) => checkList.includes(r.id)) || [];
|
||
const names = rows.map((r: any) => r.realName || r.account).join(',');
|
||
await MyConfirm({
|
||
msg: `确定删除用户「${names}」这些用户吗?`,
|
||
info: '删除后不可恢复!',
|
||
async onOk() {
|
||
try {
|
||
const gid = selectedNode.value?.id ?? null;
|
||
if (gid == null) {
|
||
Message.warning('请先选择组');
|
||
return;
|
||
}
|
||
await Apis.SysUser.batchDelete({
|
||
data: { userIds: batchDeleteSelected.value, groupId: gid }
|
||
});
|
||
Message.success('删除成功!');
|
||
tableRef.value?.reloadData(OperatorEnum.del);
|
||
} catch (e: any) {
|
||
console.warn(e?.message || '删除失败');
|
||
}
|
||
},
|
||
});
|
||
}
|
||
|
||
/* ================= 批量设置状态 ================= */
|
||
const statusModal = ref(false);
|
||
const statusSubmitting = ref(false);
|
||
const statusValue = ref<number>(0);
|
||
const statusSelectedRows = ref<any[]>([]);
|
||
|
||
function openBatchStatus(checkList: number[]) {
|
||
if (!checkList?.length) {
|
||
Message.error('请选择待设置用户!');
|
||
return;
|
||
}
|
||
const rows = tableRef.value?.list.filter((r: any) => checkList.includes(r.id)) || [];
|
||
statusSelectedRows.value = rows;
|
||
// 默认以第一个选中用户当前状态作为默认选中项
|
||
statusValue.value = rows[0]?.disabled ? 1 : 0;
|
||
statusModal.value = true;
|
||
}
|
||
|
||
async function confirmBatchStatus() {
|
||
if (!statusSelectedRows.value.length) return;
|
||
statusSubmitting.value = true;
|
||
try {
|
||
const userIds = statusSelectedRows.value.map(r => r.id);
|
||
await Apis.SysUser.batchChangeStatus({
|
||
data: { userIds, disabled: statusValue.value === 1 }
|
||
});
|
||
Message.success('设置成功!');
|
||
statusModal.value = false;
|
||
tableRef.value?.reloadData(OperatorEnum.sel);
|
||
} catch (e: any) {
|
||
console.warn(e?.message || '设置失败');
|
||
} finally {
|
||
statusSubmitting.value = false;
|
||
}
|
||
}
|
||
|
||
/* ================= 组内排序 ================= */
|
||
const sortModal = ref(false);
|
||
const sortRow = ref<any>(null);
|
||
function openSort(row: any) {
|
||
sortRow.value = row;
|
||
sortModal.value = true;
|
||
}
|
||
function onSortSaved() {
|
||
tableRef.value?.reloadData(OperatorEnum.sel);
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss">
|
||
.group-user-page .page-panel {
|
||
left: 348px !important;
|
||
}
|
||
|
||
.batch-status-body {
|
||
padding: 8px 12px;
|
||
|
||
.user-list-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.user-list-label {
|
||
flex-shrink: 0;
|
||
color: #666;
|
||
line-height: 22px;
|
||
padding-top: 2px;
|
||
}
|
||
|
||
.user-list {
|
||
flex: 1;
|
||
margin: 0;
|
||
padding-left: 18px;
|
||
color: #333;
|
||
line-height: 22px;
|
||
|
||
li {
|
||
margin-bottom: 6px;
|
||
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
.status-radio-row {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.status-label {
|
||
flex-shrink: 0;
|
||
color: #666;
|
||
margin-right: 12px;
|
||
}
|
||
}
|
||
</style>
|