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
+6
View File
@@ -50,6 +50,12 @@
})();
</script>
<title><%- title %></title>
<!-- 选人弹窗(T10 移植自 notice/front 的 UserCheckModal)依赖的全局库:
与 notice 同一形态——静态放 public/libs、不走 npm,锁在 notice 现行那份文件上(版本随库文件本身)。
只这一处引用;ztree 样式里的图片相对它自己的目录解析。 -->
<link rel="stylesheet" href="./libs/ztree/zTreeStyle.css" />
<script src="./libs/jquery.min.js"></script>
<script src="./libs/ztree/jquery.ztree.all.min.js"></script>
<%- injectScript %>
</head>
+2
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

File diff suppressed because one or more lines are too long
+378
View File
@@ -0,0 +1,378 @@
/*-------------------------------------
zTree Style
version: 3.4
author: Hunter.z
email: hunter.z@263.net
website: http://code.google.com/p/jquerytree/
-------------------------------------*/
.ztree * {
padding: 0;
margin: 0;
font-size: 14px;
font-family: Verdana, Arial, Helvetica, AppleGothic, sans-serif
}
.ztree {
margin: 0;
padding: 5px;
color: #333
}
.ztree li {
padding: 0;
margin: 0;
list-style: none;
line-height: 17px;
text-align: left;
white-space: nowrap;
outline: 0
}
.ztree li ul {
margin: 0;
padding: 0 0 0 18px
}
.ztree li ul.line {
background: url(./img/line_conn.png) 0 0 repeat-y;
}
.ztree li a {
padding-right: 3px;
margin: 0;
cursor: pointer;
height: 21px;
color: #333;
background-color: transparent;
text-decoration: none;
vertical-align: top;
display: inline-block
}
.ztree li a:hover {
text-decoration: underline
}
.ztree li a.curSelectedNode {
padding-top: 0px;
background-color: #e5e5e5;
color: black;
height: 21px;
opacity: 0.8;
}
.ztree li a.curSelectedNode_Edit {
padding-top: 0px;
background-color: #e5e5e5;
color: black;
height: 21px;
border: 1px #666 solid;
opacity: 0.8;
}
.ztree li a.tmpTargetNode_inner {
padding-top: 0px;
background-color: #aaa;
color: white;
height: 21px;
border: 1px #666 solid;
opacity: 0.8;
filter: alpha(opacity=80)
}
.ztree li a.tmpTargetNode_prev {}
.ztree li a.tmpTargetNode_next {}
.ztree li a input.rename {
height: 14px;
width: 80px;
padding: 0;
margin: 0;
font-size: 12px;
border: 1px #585956 solid;
*border: 0px
}
.ztree li span {
line-height: 21px;
margin-right: 2px
}
.ztree li span.button {
line-height: 0;
margin: 0;
padding: 0;
width: 21px;
height: 21px;
display: inline-block;
vertical-align: middle;
border: 0 none;
cursor: pointer;
outline: none;
background-color: transparent;
background-repeat: no-repeat;
background-attachment: scroll;
background-image: url("./img/bootstrap.png");
}
.ztree li span.button.chk {
width: 13px;
height: 13px;
margin: 0 2px;
cursor: auto
}
.ztree li span.button.chk.checkbox_false_full {
background-position: -5px -5px;
}
.ztree li span.button.chk.checkbox_false_full_focus {
background-position: -5px -26px;
}
.ztree li span.button.chk.checkbox_false_part {
background-position: -5px -48px;
}
.ztree li span.button.chk.checkbox_false_part_focus {
background-position: -5px -68px;
}
.ztree li span.button.chk.checkbox_false_disable {
background-position: -5px -89px;
}
.ztree li span.button.chk.checkbox_true_full {
background-position: -26px -5px;
}
.ztree li span.button.chk.checkbox_true_full_focus {
background-position: -26px -26px;
}
.ztree li span.button.chk.checkbox_true_part {
background-position: -26px -48px;
}
.ztree li span.button.chk.checkbox_true_part_focus {
background-position: -26px -68px;
}
.ztree li span.button.chk.checkbox_true_disable {
background-position: -26px -89px;
}
.ztree li span.button.chk.radio_false_full {
background-position: -47px -5px;
}
.ztree li span.button.chk.radio_false_full_focus {
background-position: -47px -26px;
}
.ztree li span.button.chk.radio_false_part {
background-position: -47px -47px;
}
.ztree li span.button.chk.radio_false_part_focus {
background-position: -47px -68px;
}
.ztree li span.button.chk.radio_false_disable {
background-position: -47px -89px;
}
.ztree li span.button.chk.radio_true_full {
background-position: -68px -5px;
}
.ztree li span.button.chk.radio_true_full_focus {
background-position: -68px -26px;
}
.ztree li span.button.chk.radio_true_part {
background-position: -68px -47px;
}
.ztree li span.button.chk.radio_true_part_focus {
background-position: -68px -68px;
}
.ztree li span.button.chk.radio_true_disable {
background-position: -68px -89px;
}
.ztree li span.button.switch {
width: 21px;
height: 21px
}
.ztree li span.button.root_open {
background-position: -105px -63px
}
.ztree li span.button.root_close {
background-position: -126px -63px
}
.ztree li span.button.roots_open {
background-position: -105px 0;
}
.ztree li span.button.roots_close {
background-position: -126px 0;
}
.ztree li span.button.center_open {
background-position: -105px -21px;
}
.ztree li span.button.center_close {
background-position: -126px -21px;
}
.ztree li span.button.bottom_open {
background-position: -105px -42px;
}
.ztree li span.button.bottom_close {
background-position: -126px -42px;
}
.ztree li span.button.noline_open {
background-position: -105px -84px;
}
.ztree li span.button.noline_close {
background-position: -126px -84px;
}
.ztree li span.button.root_docu {
background-position: -105px -63px;
}
.ztree li span.button.roots_docu {
background-position: -84px 0;
}
.ztree li span.button.center_docu {
background-position: -84px -21px;
}
.ztree li span.button.bottom_docu {
background-position: -84px -42px;
}
.ztree li span.button.noline_docu {
background: none;
}
.ztree li span.button.ico_open {
margin-right: 2px;
background-position: -147px -21px;
vertical-align: top;
*vertical-align: middle
}
.ztree li span.button.ico_close {
margin-right: 2px;
margin-right: 2px;
background-position: -147px 0;
vertical-align: top;
*vertical-align: middle
}
.ztree li span.button.ico_docu {
margin-right: 2px;
background-position: -147px -43px;
vertical-align: top;
*vertical-align: middle
}
.ztree li span.button.edit {
margin-left: 2px;
margin-right: -1px;
background-position: -189px -21px;
vertical-align: top;
*vertical-align: middle
}
.ztree li span.button.edit:hover {
background-position: -168px -21px;
}
.ztree li span.button.remove {
margin-left: 2px;
margin-right: -1px;
background-position: -189px -42px;
vertical-align: top;
*vertical-align: middle
}
.ztree li span.button.remove:hover {
background-position: -168px -42px;
}
.ztree li span.button.add {
margin-left: 2px;
margin-right: -1px;
background-position: -189px 0;
vertical-align: top;
*vertical-align: middle
}
.ztree li span.button.add:hover {
background-position: -168px 0;
}
ul.tmpTargetzTree {
background-color: #FFE6B0;
opacity: 0.8;
filter: alpha(opacity=80)
}
span.tmpzTreeMove_arrow {
width: 16px;
height: 21px;
display: inline-block;
padding: 0;
margin: 2px 0 0 1px;
border: 0 none;
position: absolute;
background-color: transparent;
background-repeat: no-repeat;
background-attachment: scroll;
background-position: -168px -84px;
background-image: url("./img/bootstrap.png");
z-index: 10000;
}
span.tmpzTreeMove_prev {
background: red;
}
ul.ztree.zTreeDragUL {
margin: 0;
padding: 0;
position: absolute;
width: auto;
height: auto;
overflow: hidden;
background-color: #cfcfcf;
border: 1px #00B83F dotted;
opacity: 0.8;
filter: alpha(opacity=80)
}
.ztreeMask {
z-index: 10000;
background-color: #cfcfcf;
opacity: 0.0;
filter: alpha(opacity=0);
position: absolute
}
+64
View File
@@ -0,0 +1,64 @@
/**
* 管理员两表的维护接口(后端 `AdminDomainController`,T10 方案 §5.3)。
*
* 六端点分两组守卫:校级管理员摊 **应用设置行** `/admin/app`(角色设置 tab 与字典 API 同一行),
* 单表单管理员摊 **表单设计行** `/admin/forms`(入口在设计器与表单列表)。
*
* 三处与直觉不同:
* 1. **写操作直接回操作后的全量列表**(不分页),前端不必再发一次 GET;
* 2. **删除按库内行 id**(方案 §5.3 的端点形态),后端还会按 `(id, 本次域)` 复核归属;
* 3. **`schId` 只在超管显式切目标域时带**,校级传别的值即被 FormAdminGuard 拒(同数据面口径)。
*/
import { alovaInstance } from "@/utils/alova/alova-iview";
import type { AdminAddRequest, AdminRow } from "@/types/admin";
const BASE = "/api/form/admin";
type Scope = { schId?: number };
function paramsOf(params: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === "") continue;
out[k] = v;
}
return out;
}
class AdminApi {
/** 校级表单管理员全量列表 */
schoolAdmins(scope: Scope = {}) {
return alovaInstance.Get<AdminRow[]>(`${BASE}/school-admins`, { params: paramsOf({ ...scope }) });
}
/** 批量添加(幂等:已在表里的人不重复建行,也不报错) */
addSchoolAdmins(body: AdminAddRequest) {
return alovaInstance.Post<AdminRow[]>(`${BASE}/school-admins`, body);
}
/** 删除(防呆:删到剩 0 由后端按业务规则拒) */
removeSchoolAdmin(id: number, scope: Scope = {}) {
return alovaInstance.Delete<AdminRow[]>(`${BASE}/school-admins/${id}`, {
params: paramsOf({ ...scope })
});
}
/** 单表单管理员全量列表 */
formAdmins(formUuid: string, scope: Scope = {}) {
return alovaInstance.Get<AdminRow[]>(`${BASE}/forms/${formUuid}/admins`, {
params: paramsOf({ ...scope })
});
}
addFormAdmins(formUuid: string, body: AdminAddRequest) {
return alovaInstance.Post<AdminRow[]>(`${BASE}/forms/${formUuid}/admins`, body);
}
removeFormAdmin(formUuid: string, id: number, scope: Scope = {}) {
return alovaInstance.Delete<AdminRow[]>(`${BASE}/forms/${formUuid}/admins/${id}`, {
params: paramsOf({ ...scope })
});
}
}
export const adminApi = new AdminApi();
+11 -3
View File
@@ -1,14 +1,14 @@
import { alovaInstance } from "@/utils/alova/alova-iview";
import type { IModule } from "@/types/Basics";
import type { SchoolTreeNode } from "@/types/admin";
/**
* 基础平台接口(notice/front 的 api/module 形态 + flow-center base.ts 同构):
* 用户信息与已授权模块列表(左导航数据源)。
*
* 模块行在 UserBase 按本应用(app_code=form-center)配置,path 须逐字对上
* router/routes.ts(手册 §2.2 表);<b>path 空的行是纯权限锚点</b>(如 customId
* fc:scope:all),只由后端 DataScopeService 扫描授权集合消费,前端按 utils/menuNav.ts
* 的规则跳过渲染——接口本身不过滤,前端拿到的仍是全量授权树。
* router/routes.ts(手册 §2.2 表 v1.5 五行版)。数据范围升级位不再挂在模块行上:
* T10 起由后端管理员两表(school_admin / form_admin)承载,前端只在 /admin/app 的角色设置 tab 维护它。
*
* 写法与 Notice 的一处差异:Notice 用 `new Method<T>(...)`,而 alova 3.5 的 Method 第一个
* 泛型位是 AlovaGenerics(不是响应类型),strict vue-tsc 下报 TS2344;本端改用实例方法
@@ -34,6 +34,14 @@ class BaseApi {
params: appCode ? { appCode } : undefined
});
}
/**
* 本校组织树(教师版)——选人弹窗 UserCheckModal 的 loader,形态同 notice。
* 没有 schId 入参:域只从会话推导,因此候选天然限在本校树里(跨校的人在接口层就给不出来)。
*/
getSchoolAll() {
return alovaInstance.Get<SchoolTreeNode[]>(`${BASE}/GetSchoolAll`);
}
}
export const baseApi = new BaseApi();
Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 B

+94
View File
@@ -0,0 +1,94 @@
/**
* 管理员两表的本地 Mock(vite-plugin-mock,仅 `npm run dev:mock` 生效)。
*
* 用途与背景同 baseapi.ts:本机没有 UserBase 授权数据(执行手册 §5 P1 降级取证先例),
* 角色设置 tab 与「表单管理员」弹窗的两表 CRUD 往返只能在 mock 里实拍。
* 桩刻意把后端三条规则也演一遍,好让冒烟脚本能断言到"前端没有把规则写丢":
* ① 重复添加幂等(不重复建行、不报错);② 校级管理员删到剩 0 拒;③ 写操作回全量列表。
*/
import type { AdminRow } from "@/types/admin";
const ok = (data: unknown) => ({ code: 0, msg: "", data });
const fail = (msg: string) => ({ code: 500, msg, data: null });
let seq = 1;
const schoolAdmins: AdminRow[] = [];
const formAdmins: Record<string, AdminRow[]> = {};
function row(userId: number, userName: string): AdminRow {
return { id: seq++, userId, userName };
}
/** 追加(幂等:已存在的 userId 跳过,姓名快照不覆盖) */
function merge(list: AdminRow[], users: { userId: number; userName: string }[]): AdminRow[] {
for (const item of users || []) {
if (!item || item.userId == null) continue;
if (!list.some((r) => r.userId === Number(item.userId))) {
list.push(row(Number(item.userId), String(item.userName || "")));
}
}
return list;
}
/** 路径里的 id / formUuid:vite-plugin-mock 不保证把 REST 段塞进 query,直接从 url 取最稳 */
function idFrom(url: string): number | null {
const matched = /\/(\d+)(?:\?|$)/.exec(url || "");
return matched ? Number(matched[1]) : null;
}
function formUuidFrom(url: string): string {
const matched = /\/forms\/([\w-]+)\/admins/.exec(url || "");
return matched ? matched[1] : "";
}
export default [
{
url: "/api/form/admin/school-admins",
method: "get",
response: () => ok(schoolAdmins.slice())
},
{
url: "/api/form/admin/school-admins",
method: "post",
response: ({ body }: any) => ok(merge(schoolAdmins, body?.users || []))
},
{
url: "/api/form/admin/school-admins/:id",
method: "delete",
response: ({ url }: any) => {
const id = idFrom(url);
if (schoolAdmins.length <= 1) return fail("本校至少保留一名校级表单管理员,最后一名不可删除");
const index = schoolAdmins.findIndex((r) => r.id === id);
if (index < 0) return fail("校级管理员记录不存在:" + id);
schoolAdmins.splice(index, 1);
return ok(schoolAdmins.slice());
}
},
{
url: "/api/form/admin/forms/:formUuid/admins",
method: "get",
response: ({ url }: any) => ok((formAdmins[formUuidFrom(url)] || []).slice())
},
{
url: "/api/form/admin/forms/:formUuid/admins",
method: "post",
response: ({ url, body }: any) => {
const uuid = formUuidFrom(url);
formAdmins[uuid] = formAdmins[uuid] || [];
return ok(merge(formAdmins[uuid], body?.users || []));
}
},
{
url: "/api/form/admin/forms/:formUuid/admins/:id",
method: "delete",
response: ({ url }: any) => {
const uuid = formUuidFrom(url);
const id = idFrom(url);
const list = formAdmins[uuid] || [];
const index = list.findIndex((r) => r.id === id);
if (index < 0) return fail("表单管理员记录不存在:" + id);
list.splice(index, 1);
return ok(list.slice());
}
}
];
+38 -17
View File
@@ -6,7 +6,7 @@
*
* 形状严格对齐后端 FormBaseController.toFrontendTree 的下发字段
* (id/modId/name/parentId/page/level/icon/orderNumber/usable/children,信封 {code,msg,data}),
* 模块行取执行手册 §2.2 的建议表(中心管理员四模块行 + 我的填写 + 数据全量权限锚点行)。
* 模块行取执行手册 §2.2 的 v1.5 现行表(中心管理员四行 + 我的填写,共五行)。
*/
import type { IModule } from "@/types/Basics";
@@ -53,9 +53,9 @@ const OWN_MODULES: IModule[] = [
{
id: 9104,
modId: 9104,
name: "字典管理",
name: "应用设置",
parentId: 0,
page: "/admin/dicts",
page: "/admin/app",
level: 1,
icon: "zidingyi",
orderNumber: 4,
@@ -74,21 +74,11 @@ const OWN_MODULES: IModule[] = [
orderNumber: 5,
usable: true,
children: []
},
{
// path 空行 = 纯权限锚点(customId=fc:scope:all):后端按授权集合扫描消费,
// 前端导航渲染必须跳过(手册 §2.2)。modId 由后端 resolveModId 回退主键 id(非数字 customId)
id: 9106,
modId: 9106,
name: "数据全量",
parentId: 0,
page: "",
level: 1,
icon: "",
orderNumber: 6,
usable: true,
children: []
}
// v1.5(T10)起中心应用就这五行:字典管理行撤(迁入 /admin/app 的页内 tab)、
// "数据全量"path 空锚点行撤(UserBase 侧 customId 权限锚点协议退役,数据范围升级改由后端管理员两表承载)。
// 本地要复现旧六行版的登录态(V46 的"不再出现字典管理导航项")请用 t10 冒烟脚本里的临时树覆盖,
// 不要把遗留行写回这份 mock——它是现行配置的镜像。
];
/**
@@ -191,5 +181,36 @@ export default [
if (target && target.toLowerCase() !== "form-center") return ok([]);
return ok(OWN_MODULES);
}
},
{
// 选人弹窗的组织树桩(T10):形状=后端 SchoolModels.Node(isParent=true 是组、false 是人员叶子)。
// id 即 UserBase 用户主键,提交给 /school-admins 与 /forms/{uuid}/admins 的就是它。
url: "/api/base/GetSchoolAll",
method: "get",
response: () =>
ok([
{
id: 3260,
name: "云海附属中学",
isParent: true,
children: [
{
id: 5001,
name: "教务处",
isParent: true,
children: [
{ id: 186532, name: "Mock 管理员", isParent: false, children: [] },
{ id: 186601, name: "Mock 教务主任", isParent: false, children: [] }
]
},
{
id: 5002,
name: "高一段",
isParent: true,
children: [{ id: 186602, name: "Mock 老师甲", isParent: false, children: [] }]
}
]
}
])
}
];
+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>
+16 -8
View File
@@ -8,10 +8,10 @@ import type { RouteRecordRaw } from "vue-router";
* 两处对齐约束:
* ① path 必须与 UserBase app_module.path 逐字一致(手册 §2.2 表;layout 优先按 path
* 匹配菜单高亮与面包屑);
* ② <b>本表刻意不写 meta.modId</b>——规范 §4「modId 全量对齐后端 /base/GetUserMod,禁臆造」:
* 本应用模块行的 customId 除权限锚点 fc:scope:all 外均未配(手册 §2.2 表为「—」),
* 后端 resolveModId 因此回退 app_module 主键 id,该值跨环境不稳定,写进路由必然失配。
* 详情页需要父级高亮时用 meta.parentId(同 Notice/flow-center 范式)。
* ② <b>本表刻意不写 meta.modId</b>——规范 §4「modId 全量对齐后端 /base/GetUserMod,禁臆造」。
* 手册 §2.2 v1.5 起中心五行的 customId 是显式数字(200~204),但把号段抄进路由就等于和
* UserBase 侧的配置耦死(改号即失配),而 layout 的高亮与面包屑本来就按 path 匹配
* (utils/menuRoute.ts 的 ROUTE_PATHS)。详情页需要父级高亮时用 meta.parentPath。
*
* 页面本体分任务落地:管理面/设计器 T6、填报页 T7、数据与统计与快应用 T8;
* 本轮 T5 只交付壳与四形态占位页,占位页不含领域逻辑。
@@ -32,7 +32,7 @@ const routes: RouteRecordRaw[] = [
meta: { title: "表单设计" },
},
{
// 设计器子页面:UserBase 侧没有这条模块行(手册 §2.2 只登记四个管理行 + 我的填写 + 权限锚点),
// 设计器子页面:UserBase 侧没有这条模块行(手册 §2.2 v1.5 只有五行:表单设计/数据管理/统计/应用设置/我的填写),
// 所以它不进左导航;高亮与面包屑由 layout 按 meta.parentPath 挂到「表单设计」行下。
// uuid 走 query 而非 path 参数,menuRoute 的 ROUTE_PATHS 才能把它当静态 path 收进去。
path: "/admin/forms/designer",
@@ -62,10 +62,18 @@ const routes: RouteRecordRaw[] = [
meta: { title: "统计" },
},
{
// 应用设置(手册 §2.2 v1.5 的行;页内两 tab:角色设置 / 字典管理,?tab= 深链)。
// tab 不占模块行、不进导航(同设计器子页的口径),高亮与面包屑就挂本行。
path: "/admin/app",
name: "fc_admin_app",
component: () => import("@/views/admin/AppSettings.vue"),
meta: { title: "应用设置" },
},
{
// 旧字典管理地址(六行版模块行时代的 path):只保留重定向,不再单独成页(T10 方案 §6.1)。
// 外部残留链接落到这里=进应用设置的字典 tab,避免出现"两条都能进、只有一条有守卫"的口径。
path: "/admin/dicts",
name: "fc_admin_dicts",
component: () => import("@/views/admin/Dicts.vue"),
meta: { title: "字典管理" },
redirect: "/admin/app?tab=dicts",
},
/* ---------------- 填报页(分享/门户/快应用直落,code 寻址) ---------------- */
{
+9
View File
@@ -46,3 +46,12 @@ export interface IModule {
usable: boolean;
children: IModule[];
}
/**
* 静态引入的全局库(index.html 里 public/libs 的 jquery + ztree):选人弹窗 UserCheckModal 按全局
* {@code $} 直调 {@code $.fn.zTree},与 notice/front 的 Basics.d.ts 同一声明形态。
* 只声明实际用到的那个名字——库是运行期全局,构建期不参与打包,因此不装 @types/jquery。
*/
declare global {
const $: any;
}
+33
View File
@@ -0,0 +1,33 @@
/**
* 管理员两表与本校组织树的前端形状(后端 `AdminModels` / `SchoolModels`,T10 方案 §5.3)。
*
* `AdminRow.id` 是库内行标识,只在本人的管理面里出现、删除时按它定位(方案 §5.3 的端点形态),
* 与答卷 uuid 那套"对外只出现 uuid"的数据面纪律不是同一类资源。
*/
/** 一行管理员(校级与单表单共用同一形状:序号 / 姓名 / 操作-删除) */
export interface AdminRow {
id: number;
userId: number;
userName: string;
}
/** 选人弹窗提交的一项(userName 是展示快照,权威在 UserBase) */
export interface AdminUserItem {
userId: number;
userName: string;
}
/** 批量添加请求体:schId 只有超管切目标域时才带 */
export interface AdminAddRequest {
schId?: number;
users: AdminUserItem[];
}
/** 本校组织树节点(ztree 直接吃:isParent=true 是组,false 是人员叶子) */
export interface SchoolTreeNode {
id: number;
name: string;
isParent: boolean;
children: SchoolTreeNode[];
}
+5
View File
@@ -0,0 +1,5 @@
type PathType = "image" | "icons";
export const requireImg = (imgPath: string, basePath?: PathType) => {
basePath = basePath || "image";
return new URL(`../../assets/${basePath}/${imgPath}`, import.meta.url).href;
};
+3 -2
View File
@@ -5,8 +5,9 @@
* 便于在冒烟脚本里直接跑断言,不必起浏览器。
*
* 三条渲染规则(执行手册 §2.2「path 空行=纯权限锚点」的前端一侧):
* ① <b>跳过判据取平台原始 path 是否为空</b>,不取解析结果:权限锚点(如 customId
* fc:scope:all 的「数据全量」行)本就不进导航,靠名字/modId 猜出路由再显示反而是假象;
* ① <b>跳过判据取平台原始 path 是否为空</b>,不取解析结果:权限锚点(快应用面的「导入/导出」
* 按钮行)本就不进导航,靠名字/modId 猜出路由再显示反而是假象——中心应用自手册 §2.2 v1.5
* 起没有 path 空行,这条规则留着是给快应用面用的;
* ② 有子节点的分组行即使自身 path 为空也保留为<b>分组头</b>(头本身不可点),
* 子节点按 ① 过滤后全空则整个分组不渲染;
* ③ 原始 path 非空但解析不出已注册路由的行<b>照常显示</b>,点击时提示配置错误——
+4 -1
View File
@@ -40,7 +40,10 @@ const NAME_ALIASES: Record<string, string> = {
答卷管理: "/admin/data",
统计: "/admin/stats",
统计分析: "/admin/stats",
字典管理: "/admin/dicts",
应用设置: "/admin/app",
// 六行版遗留名(手册 §2.2 v1.5 起字典管理不再是独立模块行):仍指向应用设置,
// 平台侧若残留这条行名的配置,点了进的是字典 tab(/admin/dicts 那条路由只做重定向)
字典管理: "/admin/app",
我的填写: "/my",
我提交的: "/my",
};
+2 -1
View File
@@ -9,7 +9,8 @@
* 1. **这里只决定"按钮画不画"**,接口侧一律会话自取(契约 §7 身份行)——藏按钮不等于授权;
* 2. **中心与快应用两种上下文的导出载体不同形**(手册 §2.2 中心表没有导出行、方案 §6.2
* 快应用表的导出行是 path 空锚点),所以按上下文分派,不硬造一个 `fc:export:*` 协议值
* (契约 §8 一期只定义了 `fc:scope:all` 一个值);
* (中心面原来只有数据范围升级位那一个锚点值,T10 已把它退役、换成后端管理员两表;
* 前端这套按钮锚点判定与此无关,消费的仍是快应用自己那两行 path 空行);
* 3. **快应用上下文拿不到锚点就是不给**(fail-closed),不做"没配就当有"的宽定。
*/
import type { IModule } from "@/types/Basics";
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
/**
* 应用设置(`/admin/app`,T10 方案 §3/§6.1):UserBase 侧一行模块行,页内两个 tab。
*
* - **角色设置** = 校级表单管理员名单(`school_admin`);
* - **字典管理** = 原 `/admin/dicts` 整页迁入(`Dicts.vue` 原样复用,守卫行随本行换到 `/admin/app`)。
*
* 两条口径:
* 1. **tab 不占模块行、不进导航**(同设计器子页),所以高亮/面包屑挂本行;`?tab=dicts` 可深链,
* 旧地址 `/admin/dicts` 由路由重定向到这里的字典 tab;
* ——深链落在模板的 {@code :model-value} 上:view-ui-plus 的 Tabs 受控 prop 是 modelValue,
* 写 {@code :value} 会落进 attrs 不被消费(T10 验收轮实拍证伪过一次),勿"顺手改回";
* 2. **一行把关两个 tab 与字典 API**:能不能进来由 UserBase 的 `/admin/app` 决定,
* 前端不做"进了页再按身份藏 tab"——藏按钮不等于授权(契约 §7 身份行)。
*/
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import AdminListPanel from "@/components/admin/AdminListPanel.vue";
import Dicts from "./Dicts.vue";
const route = useRoute();
const router = useRouter();
/** 缺省落角色设置(本页新增的主用途);只认 dicts 这一个显式值,其它 query 一律按缺省 */
const tab = computed(() => (route.query.tab === "dicts" ? "dicts" : "roles"));
function change(next: string) {
router.replace({ path: "/admin/app", query: { ...route.query, tab: next } });
}
</script>
<template>
<div class="page-wrapper yh-fill">
<div class="main-container">
<Tabs :model-value="tab" @on-click="change">
<TabPane label="角色设置" name="roles">
<AdminListPanel mode="school" />
</TabPane>
<TabPane label="字典管理" name="dicts">
<Dicts />
</TabPane>
</Tabs>
</div>
</div>
</template>
<style scoped>
/* 迁入的字典页原本是一整页(自带 page-wrapper 的填充高度),嵌进 tab 后高度要交回内容本身,
否则 yh-fill 会把 tab 面板撑成视口高、列表区跟着溢出滚动条。 */
:deep(.page-wrapper.yh-fill) {
height: auto;
padding: 0;
}
</style>
+2 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
/**
* 选项字典管理(/admin/dicts,契约 §2.1 / 方案 §4.3③b)。
* 选项字典管理(应用设置 /admin/app 的「字典管理」tab,契约 §2.1 / 方案 §4.3③b;
* T10 方案 §6.1:原独立页 /admin/dicts 整体迁入本页内 tab,页面本体不改,只换宿主与守卫行)。
*
* 两级的落点:
* - **本校字典**:`/dict/list` 只出本层,可增可删可改;
+5
View File
@@ -19,6 +19,7 @@ import ComponentLibrary from "@/components/designer/ComponentLibrary.vue";
import DesignerCanvas from "@/components/designer/DesignerCanvas.vue";
import FieldPropPanel from "@/components/designer/FieldPropPanel.vue";
import FormConfigModal from "@/components/form/FormConfigModal.vue";
import FormAdminModal from "@/components/admin/FormAdminModal.vue";
import { formApi } from "@/api/module/form";
import { dictApi } from "@/api/module/dict";
import type { DictView, FormView, VersionView } from "@/types/form";
@@ -58,6 +59,8 @@ const showErrors = ref(false);
/** 装载时的 schema 原文基线:dirty 就是「与它不一致」,不用深 watcher 追 */
const baseline = ref("");
const configRef = ref<InstanceType<typeof FormConfigModal> | null>(null);
/** 单表单管理员弹窗(与设计器的「表单配置」并排,方案 §6.4:与表单列表行操作同一形态) */
const adminRef = ref<InstanceType<typeof FormAdminModal> | null>(null);
const publishModal = reactive({ visible: false, changeLog: "" });
const locked = computed(() => viewingPublished.value);
@@ -346,6 +349,7 @@ onMounted(boot);
</div>
<div class="head-right">
<Button @click="configRef?.open()">表单配置</Button>
<Button @click="adminRef?.open(uuid, form?.name)">表单管理员</Button>
<Button @click="copyAs">另存为副本</Button>
<Button :loading="saving" :disabled="locked" @click="save()">保存草稿</Button>
<Button type="primary" :disabled="locked" @click="openPublish">发布</Button>
@@ -394,6 +398,7 @@ onMounted(boot);
</div>
<FormConfigModal ref="configRef" :form="form" @saved="onConfigSaved" />
<FormAdminModal ref="adminRef" />
<Modal v-model="publishModal.visible" title="发布新版本" :mask-closable="false">
<p class="publish-tip">
+9
View File
@@ -18,6 +18,7 @@ import { formApi } from "@/api/module/form";
import { actionColumn } from "@/utils/tableActions";
import { useElementHeight } from "@/utils/hook/useElementHeight";
import FormConfigModal from "@/components/form/FormConfigModal.vue";
import FormAdminModal from "@/components/admin/FormAdminModal.vue";
import type { FormView } from "@/types/form";
import { AUDIENCE, FORM_STATUS } from "@/types/form";
import { CODE_PATTERN, MAX_NAME_LEN } from "@/types/formSchema";
@@ -38,6 +39,8 @@ const copyModal = ref({ visible: false, source: null as FormView | null, code: "
const copying = ref(false);
/** 配置面板就地开在列表页(不进设计器也能改表单级配置,它不进版本快照) */
const configRef = ref<InstanceType<typeof FormConfigModal> | null>(null);
/** 单表单管理员弹窗(T10 方案 §6.4:列表行与设计器配置面板同一形态) */
const adminRef = ref<InstanceType<typeof FormAdminModal> | null>(null);
const activeForm = ref<FormView | null>(null);
const filtered = computed(() => {
@@ -121,6 +124,7 @@ const columns = computed(() => [
on: (row: FormView) => lifecycle(row, "enable")
},
{ key: "copy", label: "另存为副本", on: (row: FormView) => openCopy(row) },
{ key: "admin", label: "管理员", on: (row: FormView) => openAdmins(row) },
{ key: "delete", label: "删除", danger: true, on: (row: FormView) => softDelete(row) }
],
{ mode: "text", maxInline: 2 }
@@ -136,6 +140,10 @@ function openConfig(row: FormView) {
configRef.value?.open();
}
function openAdmins(row: FormView) {
adminRef.value?.open(row.formUuid, row.name);
}
function onConfigSaved(res: FormView) {
activeForm.value = res;
load();
@@ -335,6 +343,7 @@ onMounted(load);
</Modal>
<FormConfigModal ref="configRef" :form="activeForm" @saved="onConfigSaved" />
<FormAdminModal ref="adminRef" />
</div>
</template>