feat(t10-admin): 管理员体系前端——应用设置双 tab+单表单管理员+选人组件移植
build / build (push) Successful in 3m8s

- AppSettings.vue 应用设置页(基础/字典两 tab);修复 P86:Tabs 写 :value 而受控 prop 是 modelValue,致 ?tab=dicts 深链静默失效,改后深链实拍生效
- UserCheckModal.vue 自 notice 移植(view-ui-plus+ztree,sources 注入式数据源);public/libs 收录 jquery+ztree 本地件(index.html 引用)
- api/module/admin.ts + mock/adminapi.ts + types/admin.ts;AdminListPanel/FormAdminModal 组件
- routes/menuNav/menuRoute/modulePerms 适配管理员菜单与权限位;tree-icon 图标资源
This commit is contained in:
chenxing
2026-10-15 20:25:39 +08:00
parent 14d45f7e3c
commit d8f1a1fbfa
30 changed files with 1377 additions and 33 deletions
+451
View File
@@ -0,0 +1,451 @@
<template>
<Modal
v-model="modal"
title="选择用户"
width="720px"
:mask-closable="false"
:closable="false"
>
<Tabs
v-if="sources.length > 1"
:key="tabKey"
type="card"
:value="String(tabIndex)"
@on-click="tabChange"
>
<TabPane v-for="(item, index) in sources" :key="index" :label="item.name" :name="index + ''"></TabPane>
</Tabs>
<AutoComplete
@on-search="handleSearch"
@on-select="handleSelect"
placeholder="输入名字搜索"
v-model="searchTreeName"
:clearable="true"
class="auto-box"
>
<Option v-for="item in searchResult" :key="item.id" :value="item.name">
<span>{{ item.name }}</span>
<span class="fr info">{{ item.group }}</span>
</Option>
</AutoComplete>
<div class="tree-wrap fix">
<div class="tree-panel fl">
<div class="tree-box">
<div :id="ztreeDomId" class="ztree"></div>
</div>
</div>
<div class="arrow-box fl">
<svg
t="1626916477711"
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="36"
height="36"
>
<path
d="M138.935 256.078v511.844h63.42V256.078h-63.42z m126.842 0v511.844h127.215V256.078H265.777zM885.064 512L583.254 64.323v191.755H456.414v511.844h126.842v191.755L885.064 512z"
fill="#2d8cf0"
></path>
</svg>
</div>
<div class="user-panel fl">
<div class="title-box fix">
<Checkbox @on-change="checkAllUser" v-model="isCheckUserAll">全选</Checkbox>
<div class="fr">{{ tempUserList.length }}</div>
<a
href="javascript:;"
@click="delCheckUser"
v-if="tempUserList.findIndex((x: any) => x.isCheck) > -1"
>删除选中</a
>
</div>
<ul class="user-box">
<li
v-for="item in tempUserList"
:key="item.id"
:class="{ active: item.isCheck }"
@click="checkUserOne(item)"
>
{{ item.name }}
<span class="info">{{ item.group }}</span>
</li>
</ul>
</div>
</div>
<template #footer>
<Button type="primary" @click="submit">提交</Button>
<Button @click="modal = false" style="margin-left: 8px">取消</Button>
</template>
</Modal>
</template>
<script lang="ts">
/*
* 选择用户弹窗(T10 自 notice/front/src/components/UserCheckModal.vue 移植;源头再早是旧前端
* oldfront/src/components/userCheck.vue 适配 Vue3)。
* 左侧为带复选框的组织树(部门→人员,叶子=人员),勾选/取消按父子级联;
* 右侧为已选人员列表,支持全选/删除选中;提交返回去重后的用户 id 数组与用户对象数组。
*
* 相对源文件的唯一改动:两个 ztree 回调里未使用的形参加下划线前缀——本仓 type-check 开了
* noUnusedParameters,位置参数不能删(ztree 按 (event, treeId, treeNode) 顺序回调)。
* 依赖:全局 $ / $.fn.zTree 与 zTreeStyle 由 index.html 的 public/libs 静态引入(同 notice)。
*/
import { Message } from "view-ui-plus";
import { requireImg } from "@/utils/hook/useImg";
interface SelectSource {
name: string;
loader: () => Promise<any[]>;
}
export default {
name: "UserCheckModal",
props: {
/** 数据源 tab:{ name, loader },loader 返回 ztree 树数据(children 嵌套,叶子=人员) */
sources: {
type: Array as () => SelectSource[],
default: () => [],
},
/** 已选用户 id(打开时自动勾选) */
userids: {
type: Array as () => number[],
default: () => [],
},
isDisabled: {
type: Boolean,
default: false,
},
},
emits: ["update:userids", "on-submit"],
data() {
return {
ztreeDomId: "user_check_tree_" + Math.floor(Math.random() * 1e8),
modal: false,
tabIndex: 0,
/** 每次打开弹窗自增,强制重建 Tabs,避免上次 tab 高亮残留与数据错位 */
tabKey: 0,
/** 树加载序号:tab 快速切换时只渲染最后一次请求的结果 */
treeSeq: 0,
isCheckUserAll: false,
cacheMap: new Map(),
currentTree: [] as any[],
searchTreeName: "",
searchResult: [] as any[],
tempUserList: [] as any[],
};
},
computed: {
/** 当前树扁平化(含父级名称,供搜索展示) */
currentTreeFlatten(): any[] {
const flatten = (data: any[], pid: number, pname: string): any[] =>
data.reduce(
(arr: any[], { id, name, children = [], type }: any) =>
arr.concat([{ id, name, pid, type, group: pname }], flatten(children, id, name)),
[]
);
return flatten(this.currentTree, 0, "");
},
},
methods: {
show() {
this.modal = true;
// 重置到第一个 tab:显式赋值 + key 重建 Tabs,保证高亮与数据一致
this.tabIndex = 0;
this.tabKey++;
this.changeEmpty();
this.initTree();
},
tabChange(index: string) {
const idx = Number(index);
// 严格比较:v-model 场景下 iview 会把值写成字符串 name,
// 宽松相等("1" == 1)会误判“未切换”导致 tab 永远不生效
if (idx !== 0 && this.tabIndex === idx) return;
this.tabIndex = idx;
this.changeEmpty();
this.initTree();
},
handleSearch(query: string) {
let arr: any[] = [];
const searchArr = this.currentTreeFlatten.filter(
(x: any) => x.type == 2 && x.name.indexOf(query) != -1
);
for (const item of searchArr) {
!arr
.map((x) => this.getSplitId(x.id))
.includes(this.getSplitId(item.id)) && arr.push(item);
}
this.searchResult = arr;
},
handleSelect(query: string) {
if (query.indexOf(",") != -1) query = query.split(",")[0];
var treeObj = ($.fn as any).zTree.getZTreeObj(this.ztreeDomId);
var node = treeObj.getNodeByParam("name", query, null);
if (!!node) treeObj.checkNode(node, true, true, true);
},
/**
* ztree 数据预处理:拼接 pid 保证同人多组时 id 可区分;叶子标记 type=2。
* 组/用户判定优先用接口 isParent(组=文件夹),缺失时按有无子节点兜底;
* 并设置组/用户区分图标。
*/
formatTree(tree: any[], level = 0, pid: number = 0) {
tree.forEach((element: any) => {
element.open = level == 0;
element.pid = pid;
element.id = element.id + "," + pid;
element.checked = false;
if (typeof element.isParent !== "undefined") {
element.type = element.isParent ? 1 : 2;
} else {
element.type = element.children && element.children.length > 0 ? 1 : 2;
}
element.icon = requireImg(
element.type == 1 ? "tree-icon-group.png" : "tree-icon-item.png",
"image"
);
if (typeof element.children !== "undefined" && element.children.length > 0) {
element.children = this.formatTree(element.children, level + 1, this.getSplitId(element.id));
}
});
return tree;
},
initTree() {
const _this = this;
// 竞态守卫:慢请求返回时若已切换 tab,丢弃结果
const seq = ++this.treeSeq;
const cacheKey = this.tabIndex;
const source = this.sources[this.tabIndex];
if (!source) return;
const cached = this.cacheMap.get(cacheKey);
if (cached) {
this.createTree(cached);
return;
}
source
.loader()
.then((data: any[]) => {
if (seq !== _this.treeSeq) return;
_this.cacheMap.set(cacheKey, data || []);
_this.createTree(data || []);
})
.catch((e: any) => {
if (seq === _this.treeSeq) Message.error(e?.message || "加载组织数据失败");
});
},
createTree(rawData: any[]) {
const _this = this;
var jtree = JSON.parse(JSON.stringify(rawData));
var ftree = _this.formatTree(jtree);
_this.currentTree = ftree;
let treeSetting: any = {
view: {
dblClickExpand: false,
showIcon: true,
},
check: {
enable: true,
chkboxType: { Y: "ps", N: "ps" },
},
callback: {},
};
/** 勾选变化后同步右侧已选列表:收集勾选节点子树内的所有叶子 */
function formatUser(treeNode: any, checked: boolean) {
const recursionCheckIds = (tree: any): any[] => {
let arrIds: any[] = [];
for (const key in tree) {
const element = tree[key];
if (element.type == 2 && !element.chkDisabled) {
arrIds.push({ id: element.id, tid: element.tId });
}
if (typeof element.children !== "undefined" && element.children) {
arrIds = [...arrIds, ...recursionCheckIds(element.children)];
}
}
return arrIds;
};
const checkIds = recursionCheckIds([treeNode]);
const checkUserList = _this.currentTreeFlatten.filter((x: any) =>
checkIds.map((x) => x.id).includes(x.id)
);
const checkUserArr: any[] = [];
for (const element of checkUserList) {
var findObj = checkIds.find((x) => x.id == element.id);
if (
!!findObj &&
checkUserArr.findIndex((x) => x.id == element.id) === -1 &&
_this.tempUserList.findIndex((x) => x.id == element.id) === -1
) {
checkUserArr.push({ ...element, isCheck: false, tid: findObj.tid });
}
}
if (checked) {
_this.tempUserList = _this.tempUserList.concat(checkUserArr);
} else {
var ids = checkUserList.map((x) => x.id);
_this.tempUserList = _this.tempUserList.filter((x) => !ids.includes(x.id));
}
}
treeSetting.callback.onClick = (_event: any, treeId: string, treeNode: any) => {
let check = treeNode.checked;
let treeObj = ($.fn as any).zTree.getZTreeObj(treeId);
treeObj.checkNode(treeNode, !check, true);
formatUser(treeNode, treeNode.checked);
};
treeSetting.callback.onCheck = (_event: any, _treeId: string, treeNode: any) => {
formatUser(treeNode, treeNode.checked);
};
var treeObj = ($.fn as any).zTree.init($("#" + _this.ztreeDomId), treeSetting, ftree);
// 回显:已选用户打勾(callbackFlag=true 会触发 onCheck,同步右侧列表)
var nodes = treeObj.getNodesByFilter((x: any) =>
_this.userids.includes(_this.getSplitId(x.id))
);
var arr: any[] = [];
nodes.forEach((element: any) => {
var splitId = _this.getSplitId(element.id); //重复id只选择第一个
if (!arr.includes(splitId)) treeObj.checkNode(element, true, true, true);
if (_this.isDisabled) treeObj.setChkDisabled(element, true);
arr.push(splitId);
});
},
checkAllUser(check: boolean) {
this.tempUserList.forEach((element: any) => {
element.isCheck = check;
});
},
checkUserOne(item: any) {
item.isCheck = !item.isCheck;
const tlist = this.tempUserList;
this.isCheckUserAll =
tlist.length > 0 && tlist.filter((x) => x.isCheck).length == tlist.length;
},
delCheckUser() {
const _this = this;
const tlist = _this.tempUserList;
const delArr = tlist.filter((item: any) => item.isCheck);
var treeObj = ($.fn as any).zTree.getZTreeObj(_this.ztreeDomId);
delArr.forEach((element: any) => {
var node = treeObj.getNodeByTId(element.tid);
treeObj.checkNode(node, false, true, true);
});
_this.isCheckUserAll = false;
},
changeEmpty() {
this.tempUserList = [];
this.searchTreeName = "";
this.searchResult = [];
},
submit() {
const _this = this;
let idsArr = this.tempUserList.map((x: any) => _this.getSplitId(x.id));
idsArr = Array.from(new Set(idsArr));
this.$emit("update:userids", idsArr);
function delKey(obj: any, ...args: string[]) {
args.forEach((v) => delete obj[v]);
return obj;
}
const objArr: any[] = [];
idsArr.forEach((element) => {
let findObj = _this.tempUserList.find((x: any) => _this.getSplitId(x.id) == element);
findObj = delKey({ ...findObj }, "id", "isCheck", "tid", "pid", "type");
objArr.push({ ...findObj, id: element });
});
this.$emit("on-submit", objArr);
this.modal = false;
},
/** 树节点 id 形如 "1,301"(原id,父id),取真实 id */
getSplitId(strId: any) {
if (typeof strId === "string" && strId.indexOf(",") != -1) return parseInt(strId.split(",")[0]);
return strId;
},
},
};
</script>
<style lang="scss" scoped>
.tree-wrap {
height: 430px;
position: relative;
}
.tree-panel {
border: 1px solid #eee;
width: 280px;
height: 430px;
overflow: hidden;
}
.tree-box {
padding: 4px 0;
height: 428px;
width: auto;
overflow-y: auto;
border: none;
}
.user-panel {
border: 1px solid #eee;
width: 280px;
.title-box {
background: #f7f7f7;
height: 34px;
line-height: 34px;
padding: 0 12px;
a {
text-decoration: none;
float: right;
margin-right: 20px;
outline: none;
}
}
.user-box {
height: 396px;
overflow-y: auto;
li {
line-height: 32px;
padding-left: 12px;
cursor: pointer;
.info {
float: right;
margin-right: 12px;
font-size: 12px;
color: gray;
}
}
li:hover {
background: #f3f3f3;
}
li.active {
background: #e4edf6;
}
}
}
.arrow-box {
width: 128px;
text-align: center;
position: relative;
top: 50%;
margin-top: -17px;
}
.auto-box {
width: 200px;
margin-bottom: 10px;
:deep(.ivu-select-item .info) {
font-size: 12px;
color: gray;
position: relative;
top: 2px;
}
}
:deep(.ztree li) {
overflow: hidden;
}
:deep(.ztree li a) {
width: 100%;
}
:deep(.ztree li a:hover) {
text-decoration: none;
}
:deep(.ivu-auto-complete .ivu-select-dropdown-list) {
max-height: 310px;
overflow-y: auto;
}
</style>
+161
View File
@@ -0,0 +1,161 @@
<script setup lang="ts">
/**
* 管理员名单面板(T10 方案 §6.3「图二形态」:序号 / 姓名 / 操作-删除 + 添加按钮 → 选人弹窗右栏穿梭确认)。
*
* 一处实现两处用:
* - `mode="school"`:应用设置 · 角色设置 tab → `school_admin`(校级表单管理员,守 /admin/app);
* - `mode="form"`:表单列表 / 设计器的「管理员」弹窗 → `form_admin`(单表单管理员,守 /admin/forms)。
*
* 三条与直觉不同的地方:
* 1. **写操作回的是操作后的全量列表**(后端不分页,每校/每表单个位数),所以这里不再自己拼增量;
* 2. **防呆与跨校拒绝在服务端**(最后一员不可删、名单里的人必须属于本次域)——前端不做"只剩一个就禁用删除",
* 那会把后端的规则复制成两份并且必然漂移;失败就按信封 msg 提示;
* 3. **选人数据源=本校组织树**(`/api/base/GetSchoolAll`,无 schId 入参),跨校的人根本选不出来;
* 即便有人手搓请求体,落库前的归属复核仍会拒。
*/
import { computed, onMounted, ref } from "vue";
import { Message, Modal } from "view-ui-plus";
import { adminApi } from "@/api/module/admin";
import { baseApi } from "@/api/module/base";
import UserCheckModal from "@/components/UserCheckModal.vue";
import type { AdminRow } from "@/types/admin";
const props = defineProps<{
/** school=校级表单管理员;form=单表单管理员 */
mode: "school" | "form";
/** mode=form 时必填:授权落到哪张表单(对外身份是 uuid,契约 §1) */
formUuid?: string;
}>();
const rows = ref<AdminRow[]>([]);
const loading = ref(false);
const busy = ref(false);
const checkRef = ref<InstanceType<typeof UserCheckModal> | null>(null);
/** 弹窗里已勾的人(重开时回显,避免"加了三个再点进去只剩空树") */
const pickedIds = computed(() => rows.value.map((r) => r.userId));
const sources = [{ name: "本校组织", loader: () => baseApi.getSchoolAll().send() }];
const columns = computed(() => [
{ title: "序号", key: "index", width: 70, render: (h: any, p: any) => h("span", String(p.index + 1)) },
{ title: "姓名", key: "userName", minWidth: 160 },
{
title: "操作",
key: "ops",
width: 90,
render: (h: any, p: any) =>
h("a", { class: "action-del", onClick: () => confirmRemove(p.row as AdminRow) }, "删除")
}
]);
async function load() {
loading.value = true;
try {
rows.value = (await listCall().send()) || [];
} catch (e: any) {
Message.error(e?.message || "管理员名单加载失败");
} finally {
loading.value = false;
}
}
function listCall() {
return props.mode === "form" && props.formUuid
? adminApi.formAdmins(props.formUuid)
: adminApi.schoolAdmins();
}
function writeCall(users: { userId: number; userName: string }[]) {
return props.mode === "form" && props.formUuid
? adminApi.addFormAdmins(props.formUuid, { users })
: adminApi.addSchoolAdmins({ users });
}
function removeCall(id: number) {
return props.mode === "form" && props.formUuid
? adminApi.removeFormAdmin(props.formUuid, id)
: adminApi.removeSchoolAdmin(id);
}
function openPicker() {
checkRef.value?.show();
}
/** UserCheckModal 的 on-submit:[{ id, name, group... }],id 即 UserBase 用户主键 */
async function onPicked(users: any[]) {
const items = (users || [])
.filter((u) => u && u.id != null)
.map((u) => ({ userId: Number(u.id), userName: String(u.name || "").trim() }));
if (!items.length) return;
if (items.some((i) => !i.userName)) {
Message.error("选人结果里有没有姓名的行,请重选");
return;
}
busy.value = true;
try {
rows.value = (await writeCall(items).send()) || [];
Message.success("已添加管理员");
} catch (e: any) {
Message.error(e?.message || "添加失败");
} finally {
busy.value = false;
}
}
function confirmRemove(row: AdminRow) {
Modal.confirm({
title: "删除管理员",
content: `确定移除「${row.userName}」?该操作只撤销数据范围升级,不影响他已经拿到的模块行权限。`,
okText: "删除",
cancelText: "取消",
onOk: async () => {
busy.value = true;
try {
rows.value = (await removeCall(row.id).send()) || [];
} catch (e: any) {
// 防呆(最后一名不可删)与跨校拒绝都在这支:按后端 msg 原样提示
Message.error(e?.message || "删除失败");
} finally {
busy.value = false;
}
}
});
}
onMounted(load);
defineExpose({ load });
</script>
<template>
<div class="admin-panel">
<div class="toolbar">
<Button type="primary" icon="md-person-add" :disabled="busy" @click="openPicker">添加管理员</Button>
<span class="tip">
{{
mode === "school"
? "校级表单管理员:本校表单数据范围=全部,基础表单=本校提交"
: "单表单管理员:这张表单的数据查看与导出(编辑权仍走表单设计权限)"
}}
</span>
</div>
<Table :columns="columns" :data="rows" :loading="loading" size="small" :border="false" />
<UserCheckModal ref="checkRef" :sources="sources" :userids="pickedIds" @on-submit="onPicked" />
</div>
</template>
<style scoped>
.admin-panel .toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.admin-panel .tip {
color: #808695;
font-size: 12px;
}
.admin-panel :deep(.action-del) {
color: #ed4014;
}
</style>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
/**
* 单表单管理员弹窗(T10 方案 §6.4):表单列表的行操作与设计器的配置面板都挂它,
* 保证"同一弹窗"只有一份实现。每次打开都重新挂载面板,名单永远是进弹窗时拉的。
*/
import { ref } from "vue";
import AdminListPanel from "./AdminListPanel.vue";
const visible = ref(false);
const formUuid = ref("");
const formName = ref("");
function open(uuid: string, name?: string) {
if (!uuid) return;
formUuid.value = uuid;
formName.value = name || "";
visible.value = true;
}
defineExpose({ open });
</script>
<template>
<Modal v-model="visible" :title="formName ? `表单管理员|${formName}` : '表单管理员'" width="680" footer-hide>
<AdminListPanel v-if="visible" mode="form" :form-uuid="formUuid" />
</Modal>
</template>