init: UserBaseFront (frontend)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user