init: UserBaseFront (frontend)

This commit is contained in:
chenxing
2026-08-10 14:41:13 +08:00
commit eea881b616
174 changed files with 32833 additions and 0 deletions
@@ -0,0 +1,110 @@
<template>
<Modal v-model="visible" title="批量添加" :width="520" :mask-closable="false">
<div class="batch-body">
<Form :label-width="90">
<Form-Item label="父组ID:">
<span class="batch-val">{{ defaultPid ?? '-' }}</span>
</Form-Item>
<Form-Item label="组类型:">
<Select v-model="typeValue" placeholder="请选择组类型">
<Option v-for="t in groupTypeOptions" :key="t.value" :value="t.value">{{ t.label }}</Option>
</Select>
</Form-Item>
<Form-Item label="子组名称:">
<Input v-model="rawNames" type="textarea" :rows="8" placeholder="一行一个子组名称" />
</Form-Item>
</Form>
</div>
<template #footer>
<Button type="primary" :loading="loading" @click="submit">确认</Button>
<Button @click="visible = false">取消</Button>
</template>
</Modal>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import { useDefaultStore } from "@/store";
import Apis from "@/api";
const visible = defineModel<boolean>('visible', { default: false })
const props = defineProps<{
treeData: any[]
defaultPid?: number | null
defaultType?: number | null
}>()
const emit = defineEmits<{ success: [] }>()
const store = useDefaultStore();
const loading = ref(false)
const rawNames = ref('')
const typeValue = ref<number | null>(props.defaultType ?? null)
const groupTypeOptions = computed(() => {
const dict: any = (store.getSysDict as any)?.grouptype || [];
const map = (x: any) => ({ value: Number(x.key ?? x.k), label: String(x.value ?? x.v ?? x.name) });
const list = Array.isArray(dict) ? dict.map(map) : Object.entries(dict).map(([k, v]) => ({ value: Number(k), label: String(v) }));
if (list.length === 0) list.push({ value: 4, label: '班级组' });
return list;
})
function normalizeNames() {
return rawNames.value
.split(/\r?\n/)
.map((x) => x.trim())
.filter(Boolean);
}
async function submit() {
if (!props.defaultPid) {
Message.warning('请先选择父组');
return;
}
if (!typeValue.value) {
Message.warning('请选择组类型');
return;
}
const names = normalizeNames();
if (!names.length) {
Message.warning('请输入子组名称');
return;
}
loading.value = true;
try {
await Apis.SysGroup.batchAdd({
data: {
pid: props.defaultPid,
type: typeValue.value,
items: names.map((name) => ({ name })),
}
});
Message.success('添加成功');
emit('success');
visible.value = false;
} catch (e: any) {
Message.error(e?.message || '添加失败');
} finally {
loading.value = false;
}
}
watch(visible, (v) => {
if (v) {
rawNames.value = '';
typeValue.value = props.defaultType ?? groupTypeOptions.value[0]?.value ?? null;
}
})
</script>
<style lang="scss">
.batch-body {
.batch-val {
color: #333;
font-weight: 600;
}
}
</style>
+132
View File
@@ -0,0 +1,132 @@
<template>
<Modal v-model="visible" title="移动组" :width="450" :mask-closable="false">
<div class="move-body">
<div class="move-row">
<span class="move-label">待移动组:</span>
<span class="move-path">{{ movePath }}</span>
</div>
<div class="move-row move-label">移动至:</div>
<GroupTreeSearchPanel v-if="visible" v-model:curTreeNode="selectedNode" :treeApi="moveTreeApi"
:defaultSearchVisible="true" :showCloseButton="false" @on-change="onPickTarget" />
<div v-if="targetName" class="move-target">已选择目标:{{ targetName }}</div>
</div>
<template #footer>
<Button type="primary" :loading="loading" :disabled="!targetPid" @click="confirmMove">确认</Button>
<Button @click="visible = false">取消</Button>
</template>
</Modal>
</template>
<script lang="ts" setup>
import { ref, watch } from "vue";
import GroupTreeSearchPanel from '@/components/GroupTreeSearchPanel.vue'
import { Message } from "view-ui-plus";
import Apis from "@/api";
const visible = defineModel<boolean>('visible', { default: false })
const props = defineProps<{
treeData: any[]
moveId: number
moveName: string
movePath: string
}>()
const emit = defineEmits<{ saved: [payload: { movedId: number; newPid: number }] }>()
const selectedNode = ref<any>({})
const targetPid = ref<number | null>(null)
const targetName = ref('')
const loading = ref(false)
const moveTreeApi = async () => props.treeData
function findInTree(list: any[], id: number): any {
for (const n of list || []) {
if (n.id === id) return n;
if (n.children?.length) {
const r = findInTree(n.children, id);
if (r) return r;
}
}
return null;
}
function isDescendant(node: any, candidateId: number): boolean {
const stack = [...(node?.children || [])];
while (stack.length) {
const c = stack.pop();
if (c.id === candidateId) return true;
if (c.children?.length) stack.push(...c.children);
}
return false;
}
function onPickTarget(node: any) {
if (node.id === props.moveId) {
Message.warning('不能移动到自身');
return;
}
const src = findInTree(props.treeData, props.moveId);
if (src && isDescendant(src, node.id)) {
Message.warning('不能移动到自身的下级节点');
return;
}
targetPid.value = node.id;
targetName.value = node.name;
}
async function confirmMove() {
if (!targetPid.value) return;
loading.value = true;
try {
await Apis.SysGroup.move({ params: { moveId: props.moveId, pid: targetPid.value } });
Message.success('移动成功');
emit('saved', { movedId: props.moveId, newPid: targetPid.value });
visible.value = false;
} catch (e: any) {
Message.error(e?.message || '移动失败');
} finally {
loading.value = false;
}
}
watch(visible, (v) => {
if (v) {
targetPid.value = null;
targetName.value = '';
selectedNode.value = {};
}
})
</script>
<style lang="scss">
.move-body {
.move-row {
margin-bottom: 10px;
}
.move-label {
color: #888;
font-size: 13px;
}
.move-path {
color: #333;
font-weight: 600;
}
.move-target {
margin-top: 8px;
color: #2d8cf0;
font-size: 13px;
}
.ztree {
border: 1px solid #ebeef5;
border-radius: 4px;
max-height: 260px;
overflow-y: auto;
padding: 6px;
}
}
</style>
+173
View File
@@ -0,0 +1,173 @@
<template>
<Modal v-model="visible" title="子组排序" :width="520" :mask-closable="false">
<Alert show-icon>拖动左侧手柄进行排序,排序结果会实时记录</Alert>
<draggable class="sort-list" v-model="localChildren" item-key="id" handle=".drag-handle" @change="onDragChange">
<template #item="{ element, index }">
<div class="sort-item">
<span class="drag-handle">⠿</span>
<span class="sort-index">{{ index + 1 }}</span>
<span class="sort-item-name">{{ element.name || element.id }}</span>
<span class="sort-no">排序号: {{ element.sort ?? '-' }}</span>
</div>
</template>
</draggable>
<div class="sort-empty" v-if="localChildren.length === 0">
暂无子组
</div>
<template #footer>
<Button type="primary" :loading="saving" @click="saveSort" :disabled="localChildren.length === 0">
保存排序
</Button>
<Button @click="visible = false">取消</Button>
</template>
</Modal>
</template>
<script lang="ts" setup>
import { ref, watch } from "vue";
import draggable from "vuedraggable";
import { Message } from "view-ui-plus";
import Apis from "@/api";
const visible = defineModel<boolean>('visible', { default: false })
const props = defineProps<{
parentId: number | null
parentName: string
children: any[]
}>()
const emit = defineEmits<{ saved: [] }>()
const localChildren = ref<any[]>([])
const saving = ref(false)
const hasChanged = ref(false)
function resetLocal() {
localChildren.value = (props.children || []).map((x) => ({ ...x }))
hasChanged.value = false
}
function onDragChange() {
hasChanged.value = true
}
async function saveSort() {
if (!props.parentId) {
Message.warning('请先选择一个组')
return
}
if (!hasChanged.value) {
Message.info('排序未发生变化')
visible.value = false
return
}
const ids = localChildren.value.map((x) => Number(x.id)).filter((x) => Number.isFinite(x))
saving.value = true
try {
await Apis.SysGroup.sortChildren({ params: { pid: props.parentId, ids } })
Message.success('保存成功')
hasChanged.value = false
emit('saved')
visible.value = false
} catch (e: any) {
Message.error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
watch(visible, (v) => {
if (v) resetLocal()
})
</script>
<style lang="scss" scoped>
.sort-list {
max-height: 400px;
overflow-y: auto;
}
.sort-item {
display: flex;
align-items: center;
padding: 10px 12px;
margin-bottom: 6px;
background: #fafafa;
border: 1px solid #e8e8e8;
border-radius: 4px;
transition: background 0.2s;
&:hover {
background: #f0f4ff;
border-color: #c0d0ff;
}
}
/* 拖拽中:当前被拖拽的元素高亮 */
.sort-list :deep(.sort-item.sortable-chosen),
.sort-list :deep(.sort-item.sortable-drag) {
background: #fff7e6;
border-color: #fa8c16;
box-shadow: 0 6px 16px rgba(250, 140, 22, 0.4);
cursor: grabbing;
.drag-handle {
color: #fa8c16;
}
}
/* 拖拽中:落点占位位置 */
.sort-list :deep(.sort-item.sortable-ghost) {
background: #fffbe6;
border: 1px dashed #fa8c16;
opacity: 0.6;
}
.drag-handle {
cursor: grab;
display: flex;
align-items: center;
padding: 0 8px;
color: #999;
flex-shrink: 0;
&:active {
cursor: grabbing;
}
}
.sort-index {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: #577afb;
color: #fff;
font-size: 13px;
font-weight: 500;
flex-shrink: 0;
margin-right: 12px;
}
.sort-item-name {
flex: 1;
font-size: 14px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sort-loading,
.sort-empty {
padding: 40px 0;
text-align: center;
color: #999;
font-size: 14px;
}
</style>
+398
View File
@@ -0,0 +1,398 @@
<template>
<div class="group-user-tree">
<ul class="opera-ul">
<li class="add-node-li" @click="showAddNode">
<Tooltip content="新增子组" placement="top" transfer>
<SvgIcon name="tree-icon-1" />
</Tooltip>
</li>
<Tooltip content="编辑组" placement="top" transfer>
<li @click="showEditNode">
<SvgIcon name="tree-icon-2" />
</li>
</Tooltip>
<Tooltip content="子组排序" placement="top" transfer>
<li @click="showSortNode">
<SvgIcon name="tree-icon-3" />
</li>
</Tooltip>
<Tooltip content="移动组" placement="top" transfer>
<li @click="showMoveNode">
<SvgIcon name="tree-icon-4" />
</li>
</Tooltip>
<Tooltip content="删除组" placement="top" transfer>
<li @click="showDeleteNode">
<SvgIcon name="tree-icon-5" />
</li>
</Tooltip>
<Tooltip content="搜索组" placement="top" transfer>
<li class="search-node-li" @click="refTreePanel?.openSearch()">
<Icon type="md-search" />
</li>
</Tooltip>
</ul>
<GroupTreeSearchPanel ref="refTreePanel" v-model:curTreeNode="curTreeNode" :treeApi="treeApi"
:defaultSearchVisible="false" :showCloseButton="true" @on-change="treeLink" @on-tree-data="onTreeData" />
<Modal v-model="editNodeModal.modal" :title="editNodeModal.title" :width="450" :mask-closable="false">
<Form ref="refFormData" :label-width="100" :model="editNodeModal" class="myform mb-20 mr-20">
<Form-Item label="当前位置:">
{{ curNodePath.join(' > ') }}
</Form-Item>
<Form-Item label="组类型:" prop="type"
:rules="{ required: true, type: 'number', message: '请选择组类型', trigger: 'change' }">
<Select v-model="editNodeModal.type" placeholder="请选择组类型" transfer>
<Option v-for="t in groupTypeOptions" :key="t.value" :value="t.value">{{ t.label }}</Option>
</Select>
</Form-Item>
<Form-Item label="节点名称:" prop="name" :rules="{ required: true, message: '请输入节点名称', trigger: 'blur' }">
<Input ref="refNodeNameInput" maxlength="30" v-model="editNodeModal.name" />
</Form-Item>
</Form>
<template #footer>
<Button type="link" v-if="!editNodeModal.id" @click="switchToBatch">切换至批量添加</Button>
<Button type="primary" :loading="editNodeModal.loading" @click="saveEditNode">确认</Button>
<Button @click="editNodeModal.modal = false">取消</Button>
</template>
</Modal>
<BatchAddGroup v-model:visible="batchModal" :tree-data="treeData" :default-pid="curTreeNode?.id"
:default-type="batchDefaultType" @success="onBatchSuccess" />
<GroupSort v-model:visible="sortModal" :parent-id="sortParentId" :parent-name="sortParentName"
:children="sortChildrenSource" @saved="onSortSaved" />
<GroupMove v-model:visible="moveModal" :tree-data="treeData" :move-id="moveNodeId" :move-name="moveNodeName"
:move-path="moveNodePath" @saved="onMoveSaved" />
</div>
</template>
<script lang="ts" setup>
import { computed, nextTick, onMounted, reactive, ref, useTemplateRef } from "vue";
import { cloneDeep } from "lodash";
import { Message } from "view-ui-plus";
import SvgIcon from "@/components/SvgIcon.vue";
import GroupTreeSearchPanel from '@/components/GroupTreeSearchPanel.vue'
import { useDefaultStore } from "@/store";
import MyConfirm from "@/components/MyConfirm";
import Apis from "@/api";
import BatchAddGroup from "./BatchAddGroup.vue";
import GroupSort from "./GroupSort.vue";
import GroupMove from "./GroupMove.vue";
import Utils from "@/utils/index.ts";
const props = withDefaults(defineProps<{
treeApi?: () => Promise<any[]>;
}>(), {
treeApi: () => Apis.SysGroup.getTree(),
})
const treeApi = props.treeApi
const emit = defineEmits<{
'on-change': [treeNode: any]
'on-tree-data': [tree: any[]]
}>()
const curTreeNode = defineModel<any>('curTreeNode', { default: {} })
const store = useDefaultStore();
const refTreePanel = useTemplateRef('refTreePanel');
const refFormData = useTemplateRef('refFormData')
const refNodeNameInput = useTemplateRef('refNodeNameInput')
const treeData = ref<any[]>([])
const batchModal = ref(false)
const batchDefaultType = ref<number | null>(null)
const sortModal = ref(false)
const sortParentId = ref<number | null>(null)
const sortParentName = ref('')
const sortChildrenSource = ref<any[]>([])
const moveModal = ref(false)
const moveNodeId = ref<number | null>(null)
const moveNodeName = ref('')
const moveNodePath = ref('')
const editNodeModal = reactive({
modal: false,
title: '',
loading: false,
name: null as any,
id: null as any,
parentId: null as any,
type: null as any,
})
const groupTypeOptions = computed(() => {
const dict: any = (store.getSysDict as any)?.grouptype || [];
const map = (x: any) => ({ value: Number(x.key ?? x.k), label: String(x.value ?? x.v ?? x.name) });
const list = Array.isArray(dict) ? dict.map(map) : Object.entries(dict).map(([k, v]) => ({ value: Number(k), label: String(v) }));
if (list.length === 0) list.push({ value: 4, label: '班级组' });
return list;
})
const curNodePath = computed(() => {
if (!curTreeNode.value || !curTreeNode.value.getPath) return [];
return curTreeNode.value.getPath().map((x: any) => x.name)
})
function onTreeData(tree: any[]) {
treeData.value = tree;
emit('on-tree-data', cloneDeep(tree));
}
function treeLink(treeNode: any) {
const copyNode = cloneDeep(treeNode)
copyNode.children = [];
curTreeNode.value = copyNode;
emit('on-change', copyNode)
}
function currentNode() {
return curTreeNode.value?.id ? curTreeNode.value : (treeData.value[0] || {});
}
async function showAddNode() {
const node = currentNode();
editNodeModal.title = '新增子组';
refFormData.value?.resetFields();
await Utils.sleep(0);
editNodeModal.modal = true;
editNodeModal.id = null;
editNodeModal.name = null;
editNodeModal.parentId = node?.id ?? null;
editNodeModal.type = node?.type ?? groupTypeOptions.value[0]?.value ?? null;
setTimeout(() => {
refNodeNameInput.value?.focus();
}, 300);
}
async function showEditNode() {
const node = currentNode();
editNodeModal.title = '编辑组';
refFormData.value?.resetFields();
await Utils.sleep(0);
editNodeModal.modal = true;
editNodeModal.id = node?.id ?? null;
editNodeModal.name = node?.name ?? '';
editNodeModal.parentId = node?.parentId ?? null;
editNodeModal.type = node?.type ?? null;
nextTick().then(() => refNodeNameInput.value?.focus())
}
function switchToBatch() {
batchDefaultType.value = editNodeModal.type ?? null;
editNodeModal.modal = false;
batchModal.value = true;
}
function findNode(list: any[], id: number): any {
for (const n of list || []) {
if (n.id === id) return n;
if (n.children?.length) {
const r = findNode(n.children, id);
if (r) return r;
}
}
return null;
}
function showSortNode() {
const node = currentNode();
if (!node?.id) {
Message.warning('请先选择一个组');
return;
}
const found = findNode(treeData.value, node.id);
sortChildrenSource.value = found?.children ?? [];
sortParentId.value = node.id;
sortParentName.value = node.name ?? '';
sortModal.value = true;
}
function showMoveNode() {
const node = currentNode();
if (!node?.id) {
Message.warning('请先选择一个组');
return;
}
moveNodeId.value = node.id;
moveNodeName.value = node.name ?? '';
moveNodePath.value = node.getPath ? node.getPath().map((x: any) => x.name).join('/') : (node.name ?? '');
moveModal.value = true;
}
function detachNode(list: any[], id: number): any {
for (let i = 0; i < list.length; i++) {
if (list[i].id === id) {
const [n] = list.splice(i, 1);
return n;
}
if (list[i].children?.length) {
const r = detachNode(list[i].children, id);
if (r) {
if (list[i].children.length === 0) {
delete list[i].children;
list[i].isParent = false;
}
return r;
}
}
}
return null;
}
function attachNode(list: any[], pid: number, node: any): boolean {
for (const n of list) {
if (n.id === pid) {
n.children = n.children || [];
n.children.push(node);
n.isParent = true;
return true;
}
if (n.children?.length && attachNode(n.children, pid, node)) return true;
}
return false;
}
function onMoveSaved({ movedId, newPid }: { movedId: number; newPid: number }) {
const full = findNode(treeData.value, movedId);
const isParent = !!(full?.children && full.children.length);
const name = full?.name ?? moveNodeName.value;
const children = isParent ? cloneDeep(full?.children) : undefined;
const detached = detachNode(treeData.value, movedId);
if (detached) {
attachNode(treeData.value, newPid, { ...detached, isParent, children: isParent ? detached.children : undefined });
}
refTreePanel.value?.removeNode(movedId);
refTreePanel.value?.addNote(newPid, movedId, name, isParent, children);
refTreePanel.value?.selectNode(newPid, true);
}
function showDeleteNode() {
const node = currentNode();
if (!node?.id) {
Message.warning('请先选择一个组');
return;
}
MyConfirm({
title: '删除组',
msg: `确定删除名为【${node?.name}】的组?`,
info: '删除后不可恢复!',
async onOk() {
try {
await Apis.SysGroup.delete({ pathParams: { id: node?.id } });
Message.success('删除成功!');
refTreePanel.value?.removeNode(node?.id);
} catch (e: any) {
Message.error(e?.message || '删除失败');
}
},
});
}
async function saveEditNode() {
const validate = await refFormData.value?.validate();
if (!validate) {
Message.error('请填写红色星号内容!');
return;
}
const { id, name } = editNodeModal;
editNodeModal.loading = true;
try {
if (!id) {
const parentId = editNodeModal.parentId;
const data: any = { name, parentId, type: Number(editNodeModal.type) };
await Apis.SysGroup.add({ data });
Message.success('新增成功');
await refTreePanel.value?.refreshTree(parentId);
} else {
await Apis.SysGroup.edit({ pathParams: { id }, data: { name, type: Number(editNodeModal.type) } });
Message.success('编辑成功');
await refTreePanel.value?.refreshTree(id);
}
editNodeModal.modal = false;
} catch (e: any) {
Message.error(e?.message || '保存失败');
} finally {
editNodeModal.loading = false;
}
}
function onBatchSuccess() {
refTreePanel.value?.refreshTree(curTreeNode.value?.id);
}
function onSortSaved() {
refTreePanel.value?.refreshTree(curTreeNode.value?.id);
}
onMounted(async () => {
await nextTick();
refTreePanel.value?.refreshTree(curTreeNode.value?.id);
})
</script>
<style lang="scss">
.group-user-tree {
display: flex;
flex-direction: column;
height: 100%;
}
.opera-ul {
display: flex;
justify-content: space-around;
align-items: center;
border-bottom: 1px solid #ebeef5;
padding-bottom: 10px;
li {
padding: 10px;
height: 38px;
border-radius: 50%;
cursor: pointer;
&.active,
&:hover {
background-color: #f5f5f5;
}
svg {
width: 18px;
height: 18px;
}
.ivu-icon {
font-size: 18px;
}
}
li.disabled {
cursor: not-allowed;
opacity: 0.4;
&:hover {
background-color: #fff;
}
}
.add-node-li {
position: relative;
}
.search-node-li {
position: relative;
}
}
</style>
+214
View File
@@ -0,0 +1,214 @@
<template>
<div class="page-wrapper tree-wrapper">
<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>
<Input placeholder="输入用户名 / 真实姓名搜索" class="fr" search clearable suffix="ios-search" style="width: 220px"
v-model="keyword" @on-search="onSearch" @on-clear="onSearch" />
</div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
<!-- 容器组件:新增 / 编辑 -->
<AddUserContainer ref="addRef" />
<EditUserContainer ref="editRef" />
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from "vue";
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';
defineOptions({ name: "GroupUser" });
const { $utils } = 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;
tableRef.value?.refreshData();
}
function onTreeData(tree: any[]) {
treeData.value = tree;
}
function onSearch() {
dataParams.value.name = keyword.value.trim() || undefined;
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;
}>({
name: undefined,
groupId: null,
});
const tableApi = (params: { pageIndex: number; pageSize: number }) => {
return Apis.SysUser.list({
params: {
name: dataParams.value.name,
groupId: dataParams.value.groupId ?? undefined,
// 选中组织时包含子组用户;未选中时不传
withSub: dataParams.value.groupId != null ? 1 : undefined,
pageIndex: params.pageIndex,
pageSize: params.pageSize,
},
cacheFor: null,
});
};
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }))
/* ================= 用户身份(roles[])展示 ================= */
const userTypeMap: Record<number, string> = {
1: '管理员', 2: '老师', 3: '校领导', 4: '局领导',
5: '学生', 6: '家长', 7: '工作号',
};
const roleMap: Record<number, string> = {
1: '超级管理员', 2: '学校管理员', 3: '普通用户',
};
function describeRoles(roles: any): string {
if (!Array.isArray(roles) || roles.length === 0) return '-';
const userTypes: string[] = [];
const specificRoles: string[] = [];
roles.forEach((r: any) => {
const n = Number(r);
if (userTypeMap[n]) userTypes.push(userTypeMap[n]);
else if (roleMap[n]) specificRoles.push(roleMap[n]);
});
const parts: string[] = [];
if (userTypes.length) parts.push(userTypes.join('/'));
if (specificRoles.length) parts.push(specificRoles.join('/'));
return parts.length ? parts.join(' · ') : '-';
}
/* ================= 表格列 ================= */
const columns = [
{ 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: 90, align: 'center',
render: (h: any, { row }: any) =>
h('Tag', { color: row.disabled ? 'default' : 'success' },
row.disabled ? '已禁用' : '启用'),
},
{
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),
}, '编辑'),
h('a', {
style: { marginRight: '8px', color: '#ed4014' },
onClick: () => handleDelete(row),
}, '删除'),
h('a', {
style: { color: row.disabled ? '#19be6b' : '#ff9900' },
onClick: () => handleToggleStatus(row),
}, row.disabled ? '启用' : '禁用'),
];
return h('div', opList);
},
},
];
/* ================= 新增 ================= */
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) {
editRef.value?.open(row, groupOptions.value, () => {
tableRef.value?.reloadData(OperatorEnum.edit);
});
}
/* ================= 删除 ================= */
async function handleDelete(row: any) {
await MyConfirm({
msg: `确定删除用户「${row.realName || row.account}」吗?`,
info: '删除后不可恢复!',
async onOk() {
try {
await Apis.SysUser.delete({ pathParams: { id: row.id } });
Message.success('删除成功!');
tableRef.value?.reloadData(OperatorEnum.del);
} catch (e: any) {
Message.error(e?.message || '删除失败');
}
},
});
}
/* ================= 禁用 / 启用 ================= */
async function handleToggleStatus(row: any) {
const isDisabled = !!row.disabled;
await MyConfirm({
msg: `确定要${isDisabled ? '启用' : '禁用'}用户「${row.realName || row.account}」吗?`,
async onOk() {
try {
if (isDisabled) {
await Apis.SysUser.enable({ pathParams: { id: row.id } });
} else {
await Apis.SysUser.disable({ pathParams: { id: row.id } });
}
Message.success(isDisabled ? '已启用!' : '已禁用!');
tableRef.value?.reloadData(OperatorEnum.sel);
} catch (e: any) {
Message.error(e?.message || '操作失败');
}
},
});
}
</script>
<style lang="scss" scoped></style>
@@ -0,0 +1,75 @@
<template>
<UserForm v-model="visible" :initForm="initFormData" :groupOptions="groupOptions" :isEdit="false"
:loading="submitting" @submit="handleSubmit" @cancel="visible = false" />
</template>
<script setup lang="ts">
import { ref } from "vue";
import UserForm from "./UserForm.vue";
/* ---------- 对外暴露:由父组件(group-user/index)调用 ---------- */
const visible = ref(false);
// 所在组下拉数据(拍平后的组织树)
const groupOptions = ref<any[]>([]);
// 新增模式:始终传空初始值;若带 defaultGroupId 则预填 groupId
const initFormData = ref<Record<string, any>>({});
// 成功后回调
let onSuccessCallback: (() => void) | null = null;
// useForm 托管提交:loading=submitting、sendForm 发请求、onSuccess/onError 处理结果
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
(dto) => Apis.SysUser.add({ data: dto }),
{ initialForm: { account: '', realName: '', password: '', confirmPassword: '', idcard: '', phone: '', phone2: '', gender: 0, groupId: undefined, userType: [], role: undefined }, resetAfterSubmiting: true }
);
onSuccess(() => {
Message.success('新增用户成功!');
visible.value = false;
onSuccessCallback?.();
});
onError((event) => {
Message.error(event?.error?.message || '新增失败');
});
/* ---------- 打开弹窗 ---------- */
function open(groups: any[], defaultGroupId?: number | null, onSuccess?: () => void) {
groupOptions.value = groups || [];
// 若已选中某个组织节点,新增弹窗自动带入该组;未选时不带入
initFormData.value = defaultGroupId != null ? { groupId: defaultGroupId } : {};
onSuccessCallback = onSuccess || null;
visible.value = true;
}
/* ---------- 提交:由 useForm 接管 loading 与异常处理 ---------- */
async function handleSubmit(formData: Record<string, any>) {
form.value = buildPayload(formData);
await sendForm();
}
/**
* 把表单字段映射成 SysUserRequest:
* - 只提交后端 SysUserRequest 里声明的字段
* - "用户身份"(userType checkbox) + "角色属性"(role select) 合并为 roles[](去重)
*/
function buildPayload(formData: Record<string, any>) {
const roleSet = new Set<number>();
(formData.userType || []).forEach((v: any) => roleSet.add(Number(v)));
if (formData.role != null && formData.role !== '') roleSet.add(Number(formData.role));
return {
account: formData.account,
password: formData.password,
confirmPassword: formData.confirmPassword,
realName: formData.realName,
idcard: formData.idcard || undefined,
phone: formData.phone,
groupId: formData.groupId,
roles: Array.from(roleSet),
};
}
defineExpose({ open, visible });
</script>
@@ -0,0 +1,114 @@
<template>
<UserForm v-model="visible" :initForm="detailData" :groupOptions="groupOptions" :isEdit="true"
:loading="submitting" @submit="handleSubmit" @cancel="visible = false" />
</template>
<script setup lang="ts">
import { ref } from "vue";
import UserForm from "./UserForm.vue";
/* ---------- 对外暴露 ---------- */
const visible = ref(false);
const groupOptions = ref<any[]>([]);
// 编辑模式:当前编辑的用户 ID
const currentId = ref<number | null>(null);
// 编辑模式:回填数据(来自当前行,辅以 roles[] 拆分到 userType/role)
const detailData = ref<Record<string, any>>({});
// 成功后回调
let onSuccessCallback: (() => void) | null = null;
// useForm 托管提交——currentId 是 ref,闭包内 .value 在 sendForm 时取当前值
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
(dto) => Apis.SysUser.edit({ pathParams: { id: currentId.value }, data: dto }),
{ initialForm: { id: null, account: '', realName: '', password: '', confirmPassword: '', idcard: '', phone: '', phone2: '', gender: 0, groupId: undefined, userType: [], role: undefined }, resetAfterSubmiting: true }
);
onSuccess(() => {
Message.success('编辑用户成功!');
visible.value = false;
onSuccessCallback?.();
});
onError((event) => {
Message.error(event?.error?.message || '编辑失败');
});
/* ---------- 打开编辑弹窗 ---------- */
/**
* @param row - 当前行数据(来自 SysUser.list 接口返回的 SysUserVo)
* @param groups - 所在组下拉(拍平的组织树)
* @param onSuccess - 编辑成功后的回调
*/
function open(row: any, groups: any[], onSuccess?: () => void) {
if (!row?.id) {
Message.error('用户 ID 不存在');
return;
}
currentId.value = row.id;
groupOptions.value = groups || [];
detailData.value = buildInitForm(row);
onSuccessCallback = onSuccess || null;
visible.value = true;
}
/* ---------- 把行数据映射成表单回填数据 ---------- */
function buildInitForm(row: any) {
const roles: number[] = Array.isArray(row.roles) ? row.roles : [];
// 用户身份(业务身份)= 1..7;角色属性(具体角色)= 1..3
const userType = roles.filter(r => r >= 1 && r <= 7);
const role = roles.filter(r => r >= 1 && r <= 3).slice(-1)[0];
return {
id: row.id,
account: row.account || '',
realName: row.realName || '',
idcard: row.idcard || '',
phone: row.phone || '',
groupId: row.groupId,
password: '',
confirmPassword: '',
phone2: '',
gender: 0,
userType,
role,
};
}
/* ---------- 提交:由 useForm 接管 loading 与异常处理 ---------- */
async function handleSubmit(formData: Record<string, any>) {
if (!currentId.value) return;
form.value = buildPayload(formData, currentId.value);
await sendForm();
}
/**
* 把表单字段映射成 SysUserRequest:
* - 只提交后端 SysUserRequest 里声明的字段
* - "用户身份"(userType checkbox) + "角色属性"(role select) 合并为 roles[](去重)
* - 编辑时密码留空表示不修改,所以密码字段不传
*/
function buildPayload(formData: Record<string, any>, id: number) {
const roleSet = new Set<number>();
(formData.userType || []).forEach((v: any) => roleSet.add(Number(v)));
if (formData.role != null && formData.role !== '') roleSet.add(Number(formData.role));
const payload: any = {
id,
account: formData.account,
realName: formData.realName,
idcard: formData.idcard || undefined,
phone: formData.phone,
groupId: formData.groupId,
roles: Array.from(roleSet),
};
// 编辑时若填写了密码才提交(密码为空表示不修改)
if (formData.password) {
payload.password = formData.password;
payload.confirmPassword = formData.confirmPassword;
}
return payload;
}
defineExpose({ open, visible });
</script>
+295
View File
@@ -0,0 +1,295 @@
<template>
<Modal v-model="show" :mask-closable="false" width="800" scrollable :title="title"
@on-visible-change="onVisibleChange">
<Form ref="formRef" :model="form" :rules="rules" :label-width="130" class="user-form width-95">
<!-- 第 1 行:用户名 / 真实姓名 -->
<Row>
<Col span="12">
<FormItem label="用户名:" prop="account">
<Input v-model="form.account" :disabled="isEdit" placeholder="请输入用户名" />
</FormItem>
</Col>
<Col span="12">
<FormItem label="真实姓名:" prop="realName">
<Input v-model="form.realName" placeholder="请输入真实姓名" />
</FormItem>
</Col>
</Row>
<!-- 第 2 行:密码 / 确认密码(编辑时变为「重置密码」语义,但视觉一致) -->
<Row>
<Col span="12">
<FormItem label="密码:" prop="password">
<Input v-model="form.password" type="password" :placeholder="isEdit ? '不修改请留空' : '请输入密码'" />
</FormItem>
</Col>
<Col span="12">
<FormItem label="确认密码:" prop="confirmPassword">
<Input v-model="form.confirmPassword" type="password"
:placeholder="isEdit ? '不修改请留空' : '请再次确认密码'" />
</FormItem>
</Col>
</Row>
<!-- 第 3 行:身份证号 / 手机号码 -->
<Row>
<Col span="12">
<FormItem label="身份证号:" prop="idcard">
<Input v-model="form.idcard" placeholder="请输入身份证号" />
</FormItem>
</Col>
<Col span="12">
<FormItem label="手机号码:" prop="phone">
<Input v-model="form.phone" placeholder="请输入手机号码" />
</FormItem>
</Col>
</Row>
<!-- 第 4 行:手机号码2 / 性别 -->
<Row>
<Col span="12">
<FormItem label="手机号码2:">
<Input v-model="form.phone2" placeholder="请输入手机号码" />
</FormItem>
</Col>
<Col span="12">
<FormItem label="性别:">
<RadioGroup v-model="form.gender">
<Radio :label="0">女</Radio>
<Radio :label="1">男</Radio>
</RadioGroup>
</FormItem>
</Col>
</Row>
<!-- 第 5 行:所在组(整行) -->
<Row>
<Col span="24">
<FormItem label="所在组:" prop="groupId">
<Select v-model="form.groupId" placeholder="请选择所在组" filterable transfer>
<Option v-for="g in groupOptions" :key="g.id" :value="g.id" :label="g.name" />
</Select>
</FormItem>
</Col>
</Row>
<!-- 第 6 行:用户身份(checkbox 多选,整行) -->
<Row>
<Col span="24">
<FormItem label="用户身份:" prop="userType">
<CheckboxGroup v-model="form.userType" class="user-type-group">
<Checkbox v-for="opt in userTypeOptions" :key="opt.value" :label="opt.value">
{{ opt.label }}
</Checkbox>
</CheckboxGroup>
</FormItem>
</Col>
</Row>
<!-- 第 7 行:角色属性(整行) -->
<Row>
<Col span="24">
<FormItem label="角色属性:" prop="role">
<Select v-model="form.role" placeholder="请选择" clearable transfer style="width: 220px">
<Option v-for="r in roleOptions" :key="r.value" :value="r.value" :label="r.label" />
</Select>
</FormItem>
</Col>
</Row>
</Form>
<template #footer>
<Button type="primary" :loading="loading" @click="handleSubmit">提交</Button>
<Button @click="handleCancel">取消</Button>
</template>
</Modal>
</template>
<script setup lang="ts">
import { computed, reactive, watch } from "vue";
/* ---------- Props / Emits ---------- */
const props = withDefaults(defineProps<{
modelValue: boolean;
initForm?: Record<string, any>;
groupOptions?: any[];
isEdit?: boolean;
loading?: boolean;
}>(), {
initForm: () => ({}),
groupOptions: () => [],
isEdit: false,
loading: false,
});
const emit = defineEmits(['update:modelValue', 'submit', 'cancel']);
const show = computed({
get: () => props.modelValue,
set: val => emit('update:modelValue', val),
});
const title = computed(() => props.isEdit ? '编辑用户' : '新增用户');
/* ---------- 表单数据 ---------- */
const defaultForm = (): Record<string, any> => ({
id: null,
account: '',
realName: '',
password: '',
confirmPassword: '',
idcard: '',
phone: '',
phone2: '',
gender: 0,
groupId: undefined as number | undefined,
userType: [] as number[],
role: undefined as number | undefined,
});
const form = reactive(defaultForm());
// 外部 initForm 变化时回填
watch(() => props.initForm, (val) => {
if (val && Object.keys(val).length > 0) {
Object.assign(form, defaultForm(), val);
} else {
Object.assign(form, defaultForm());
}
}, { immediate: true, deep: true });
/* ---------- 用户身份(业务身份多选)---------- */
const userTypeOptions = [
{ value: 1, label: '管理员' },
{ value: 2, label: '老师' },
{ value: 3, label: '校领导' },
{ value: 4, label: '局领导' },
{ value: 5, label: '学生' },
{ value: 6, label: '家长' },
{ value: 7, label: '工作号' },
];
/* ---------- 角色属性(具体角色单选,固定枚举)---------- */
const roleOptions = [
{ value: 1, label: '超级管理员' },
{ value: 2, label: '学校管理员' },
{ value: 3, label: '普通用户' },
];
/* ---------- 校验规则 ---------- */
// 自定义确认密码校验
const validateConfirmPassword = (rule: any, value: string, callback: any) => {
if (!props.isEdit) {
// 新增:必填 + 必须与密码一致
if (!value) {
callback(new Error('请再次输入密码'));
} else if (value !== form.password) {
callback(new Error('两次输入的密码不一致'));
} else {
callback();
}
} else {
// 编辑:留空表示不修改密码;非空时必须与密码一致
if (value && value !== form.password) {
callback(new Error('两次输入的密码不一致'));
} else {
callback();
}
}
};
// 自定义密码校验(编辑时可留空)
const validatePassword = (rule: any, value: string, callback: any) => {
if (!props.isEdit && !value) {
callback(new Error('请输入密码'));
} else if (value && value.length < 6) {
callback(new Error('密码长度不能少于 6 位'));
} else {
callback();
}
};
const rules = {
account: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 30, message: '用户名长度 3-30 位', trigger: 'blur' },
],
realName: [
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
],
password: [{ validator: validatePassword, trigger: 'blur' }],
confirmPassword: [{ validator: validateConfirmPassword, trigger: 'blur' }],
phone: [
{ required: true, message: '请输入手机号码', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '请输入正确的手机号', trigger: 'blur' },
],
groupId: [
{ required: true, type: 'number' as const, message: '请选择所在组', trigger: 'change' },
],
userType: [
{ required: true, type: 'array' as const, min: 1, message: '请选择至少一个用户身份', trigger: 'change' },
],
role: [
{ required: true, type: 'number' as const, message: '请选择角色属性', trigger: 'change' },
],
};
/* ---------- 提交 ---------- */
const formRef = ref();
async function handleSubmit() {
try {
await formRef.value?.validate();
} catch {
return false;
}
emit('submit', { ...form });
}
function handleCancel() {
emit('cancel');
}
function onVisibleChange(val: boolean) {
if (!val) {
emit('cancel');
}
}
</script>
<style lang="scss" scoped>
.user-form {
margin: 0 auto;
max-height: 60vh;
overflow-y: auto;
overflow-x: hidden;
padding-right: 12px;
:deep(.ivu-form-item) {
display: block;
margin-bottom: 0;
}
:deep(.ivu-form-item-label) {
font-size: 13px;
color: #515a6e;
}
:deep(.ivu-row) {
margin-left: 0 !important;
margin-right: 0 !important;
margin-bottom: 22px;
}
:deep(.ivu-row:last-child) {
margin-bottom: 0;
}
}
/* 用户身份多选:横向换行,避免撑爆 Col */
.user-type-group {
display: flex;
flex-wrap: wrap;
gap: 0 18px;
}
</style>