- 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 图标资源
@@ -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>
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 933 B |
|
After Width: | Height: | Size: 5.4 KiB |
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
|
||||
|
After Width: | Height: | Size: 254 B |
|
After Width: | Height: | Size: 524 B |
@@ -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());
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -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: [] }]
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 寻址) ---------------- */
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>,点击时提示配置错误——
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
@@ -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` 只出本层,可增可删可改;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||