Compare commits

..
23 Commits
Author SHA1 Message Date
chenxing 73297e4b52 feat(dept): 自定义部门成员弹窗改为穿梭框,左侧全部人员、中间箭头、右侧已有成员
build / build (push) Successful in 2m34s
- 布局对齐「分配应用用户」:左「全部人员」(本校教职工候选)+ 中间 < > 箭头 + 右「已有成员」
  (原先左侧是当前成员、右侧是候选勾选,方向与本系统其他页面相反)
- 两栏都带搜索框、全选框与计数;左栏保留服务端 keyword 检索(候选上限 200)
- 交互改为待提交模式:箭头只改本地 pending,点「保存」才先 Add 再 Remove,
  取消整体丢弃;未产生变更时保存按钮禁用,底部显示「新增 N 人 · 移出 M 人」
- 支持双击行快速移动;移出后的人员回到左侧列表,方便反悔
- 全选/半选按「当前可见行」判断,关键字筛选后不会误判
2026-10-26 14:16:16 +08:00
chenxing 4f94dbdbda fix(dept): 修包含子组下表格空白,状态改回胶囊开关,人员前 10 可点,自定义左栏轻量化
build / build (push) Successful in 3m41s
- 修 bug:DeptRow 的 children 改名为 _kids —— iView Table 见行上有 children 会切树形数据模式,
  与自画的缩进/折叠打架,表现为开启「包含子组」时整表空白(关闭时 children 为空数组才恢复正常)
- 关闭「包含子组」时仍保留「N 个下级」灰标,只收起子行,层级信息不再丢
- 状态列从边框 Tag 改回胶囊开关(与「包含子组」同一套样式)
- 人员列:最多平铺 10 个姓名 + N,全部列完(≤10 人)时不可点击;
  姓名区可截断、+N 固定不收缩;列宽改为 序号70 / 名称236 / 上级130 / 人员自适应 / 状态90
- 自定义部门左栏改轻量分类列表(无边框卡片、选中浅底 + 左侧细蓝条),
  分类的排序 / 改名 / 删除 / 新增收进「分类管理」弹框:行内改名(回车或失焦保存,Esc 取消)、
  有部门时删除禁用、底部新增分类;保存排序沿用 customDeptCategorySort
- 自定义部门表格同步:状态胶囊开关、人员前 10 规则、列宽与系统部门一致
2026-10-26 11:46:44 +08:00
chenxing 51c95dd7b9 fix(dept): 部门表格改用 iView Table,来源切换去英文并固定顺序,自定义排序改为弹框拖动
build / build (push) Successful in 3m51s
- 系统部门:自写 table 换成 iView Table(border + 表头灰底 + 行 hover),
  层级仍用名称列缩进 + 折叠;状态改为可点击的边框 Tag(已启用/已停用)
- 标题行:去掉 SYSTEM / CUSTOM 英文,「包含子组」移到来源切换左侧并整体右对齐,
  切换视图时标签位置不再左右跳动
- 自定义部门:同样换 iView Table;表格内不再拖行,改为「排序管理」按钮 + 弹框拖动,
  保存后按新顺序提交 customDeptSort;操作列保留 编辑/成员/删除
- 修正 Tag 上的 @click.native(Vue 3 已移除 .native)
2026-10-23 15:10:24 +08:00
chenxing c39c0f83d7 feat(dept): 部门管理改为表格,系统精简为 5 列、自定义表格化并支持拖动排序
build / build (push) Successful in 2m53s
- 系统部门:卡片网格改为表格(序号/部门名称/上级/人员/状态),层级用缩进+折叠表达;
  去掉新增、排序与操作列,只保留启停与「包含子组」
- 人员列:memberCount + memberNames 前 3 个姓名 + N,Tooltip 展示完整预览,
  点击弹窗分页查看全部(/api/sysuser/List?groupId=,不传 withSub 只看直属)
- 自定义部门:卡片改表格(序号/名称/人员/状态/操作),行拖动排序沿用 customDeptSort,
  分类列表支持拖动(customDeptCategorySort),原有新增/编辑/删除/成员/上下移保留
- DeptCard.vue、CustomDeptCard.vue 标记为已废弃(无引用,可安全删除)
2026-10-23 10:31:04 +08:00
chenx 83a73dfcc8 feat(dept): 部门管理新增 CUSTOM 自定义部门页签
build / build (push) Successful in 2m7s
- index.vue 来源切换「系统部门|自定义部门」,SYSTEM 原 DOM 原样 v-if 包装零逻辑改动,学校上下文共用
- CustomDeptPanel/CustomDeptCard:分类增删改排序(非空禁删)、部门增改移启停软删(风险二次确认)
- CustomMemberModal:候选走 CandidateUsers(服务端同口径排除学生组),搜索+勾选+批量添加
- apiDefinitions/globals.d.ts 补 17 个 /api 契约(key 与后端 operationId 严格一致)
- 遗留:npm run api:gen 待后端可起环境补跑校验
2026-10-22 09:36:18 +08:00
chenxing b8c55a0654 fix(dept): 页面骨架改为标准 tree-wrapper + main-container,去掉自造 iView Card
build / build (push) Successful in 2m32s
- 根容器 main-wrapper → page-wrapper tree-wrapper;右侧改挂 .main-container 白卡
  (主题 skin 已提供背景/边框/圆角/阴影/margin 16/padding 16),删除 iView Card 与配套的
  .dept-panel 撑高补丁
- 卡片标题降级为卡片内 .panel-title 一行;两卡间隙与四周留白回到规范 16px,
  与 group-user 等 13 个标准骨架页逐像素对齐(此前 12px 间隙 + 自造卡片,灰底露出过多)
- 删除为撑高写的 flex hack(.dept-panel / ivu-card-body flex:1 等),高度交给主题
2026-10-21 16:01:59 +08:00
chenxing 7cb8b7b974 fix(dept): 启停部门不再重刷左侧树;说明条改回 iView 原生 Alert
build / build (push) Successful in 2m18s
- 启停后改走 syncTreeData():只替换 fullTree 数据源,不调用 refreshTree
  (树只渲染到学校层,部门节点不在树上,整树重建只会白丢展开态与滚动位置)
- 删掉 .usage-note 上的 background/border/color !important 覆盖,外观交回 iView Alert 组件,
  页面只保留 margin-bottom 间距
2026-10-21 15:33:54 +08:00
chenxing 16d49375f1 fix(dept): 撤掉树节点开关、修文案误判;部门管理迁入「基础设置」并改走接口
build / build (push) Successful in 2m20s
- 树恢复纯 zTree:去掉 MyTree.attachNodeControl 的调用与相关定位样式(绝对定位在节点行内会漂移、视觉突兀)
- 修正卡片副文案误判:原来按 type!==7 判「学生组·非部门」,实际教师组下挂的是普通组(type=1),
  改为只对 type===8 判学生组,其余都算部门(部门身份由「挂在教师组下」决定,不由自身 type 决定)
- 菜单:部门管理从「权限管理」迁入新建的「基础设置」分组(menu modId 1115,routes meta.parentId 同步为 modId 而非 menu.id)
- 「包含子组」改为读写学校节点的 includeChildren 字段(去掉 localStorage),切换即调 SetIncludeChildren
- 布局:说明条移入面板内部并改主题浅绿(iView success 在本主题下是灰绿 #D3E0D8,不跟随);
  Card 面板 flex 撑满高度;树容器收掉外层滚动条,滚动统一交给 MyTree 内部 .ztree
2026-10-21 15:12:55 +08:00
ops b2a4eeb2f3 fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:24:29 +00:00
chenxing 8bbe3c654b feat(dept): 部门管理改卡片视图,树只到学校层+含子组开关,修复只显示教师组
build / build (push) Successful in 2m22s
- 右侧由表格改为部门卡片网格;有下级部门的部门渲染为跨整行的分组卡(办公室 → 校办/宣传/财务/党建),叶子部门为单卡
- 左侧沿用 zTree,剪枝到「学校」层;学校行尾挂「包含子组」开关(MyTree 新增 attachNodeControl,不污染 node.name、不触发节点选中)
- 按访问级别分视角:超管/局端显示树,学校管理员无树直接列本校部门(多校走顶部下拉)
- 修复「只显示教师组一行」:右侧数据改为从全量组树按 id 回查 children,不再使用 treeLink 中已清空 children 的节点
2026-10-21 10:31:48 +08:00
ops e83983b41c ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:37:01 +00:00
chenxing 3605a91b78 feat(module): 子模块自定义ID/路径改为选填,适配按钮权限型子模块
build / build (push) Successful in 1m51s
- 校验规则改为 computed:仅根模块强制 path/customId,子模块两者皆可空
- 占位文案按 openingIsChild 切换,提示「作按钮权限时可留空」
- 提交前 path 做 trim,空值传 undefined(后端归一并放开 nullable)
2026-10-21 08:53:00 +08:00
chenxing 24ae7b13fe 部门线P0最小批:极简部门管理页(只读树+状态徽标+启停确认,权限管理分组下)
build / build (push) Successful in 1m52s
2026-10-20 22:37:43 +08:00
chenxing 1a011656ec ci: apk 源不可达时按可达性自动切换镜像(修复流水线卡死在第一步)
build / build (push) Successful in 2m50s
现象:10-20 12:18 / 12:19 两轮构建都失败在第一步 apk add:
  fetch https://dl-cdn.alpinelinux.org/alpine/v3.20/main: Permission denied(每个源卡满 60s)
  ERROR: unable to select packages: curl (no such package)
后续 clone / 构建 / 打包 / 建档全部被 skip,代码根本没被编译到。
同一时刻本机访问该源是 200,说明是 runner 容器侧网络受限。

改法:装包前用 busybox wget 依次探测 dl-cdn / 阿里云 / 腾讯云 / 华为云,
取第一个可达源替换 /etc/apk/repositories 后再 apk add;四源全不可达则显式报错退出,
不再以 120 秒超时的形式掩盖真实原因。
2026-10-20 14:18:45 +08:00
chenxing 32b73ba407 fix(授权): 父级勾选态改「任一子节点选中即选中」+ 无模块应用可授权(组授权/角色授权)
build / build (push) Failing after 2m6s
1. 父级选中态(两页口径统一)
   isAppChecked 由「全部子模块都勾选才 true」改为「任一子孙被勾选即 true」,
   新增 isModuleAnyChecked 替代不再使用的 isModuleAllChecked;buildModuleNode 同步。
   样式把 zTree checkbox_true_part / _focus 的 background-position 指向 checkbox_true_full
   的蓝勾位置——父节点 checked=true 但子节点未全选时,原生是"蓝底横杠"半选,
   折叠态几乎看不出部分授权,改成蓝勾后与全选视觉一致。

2. 无子模块应用(如「一周安排」)勾选后刷新即失效
   - GroupAuth:这类应用没有模块明细,原 buildModuleDiff 前后快照恒为空、根本不发请求。
     改为单独走 moduleId=0 的「整应用」提交(后端翻译成 -appId 哨兵记录);
     回显读后端应用级 checked / inheritFlag;setLocalAppInherit 支持应用级。
   - AppModuleCheckTree:onTreeCheck 直接记 app.checked;saveNow 把后端约定的 -appId
     并入 moduleIds —— 角色授权是模块 id 全量覆盖,不带哨兵每一项保存都会被静默清掉。

后端配套改动见 UserBaseService 同名提交(AppLevelModuleId / resolveModuleIds / VO 新增 checked)。
2026-10-20 12:18:54 +08:00
chenxing e8ab9280bf feat(loading): 表格加载接通道 .ysch-spin(v1.11 试点第一端)——TablePageAlovaNew/Alova 插槽挂 ysch-spin,main.scss 改布局块+文案 var(--brand),删 loading.gif 两份副本(ModuleManage 按钮权限既有改动不在本提交)
build / build (push) Successful in 2m26s
2026-10-14 18:13:13 +08:00
chenxing d48153ced9 fix(状态徽标): 正常/禁用改主题色描边徽标(对齐「当前生效」样式)
build / build (push) Successful in 1m51s
view-ui-plus Tag 预设色的 border prop 渲染仍实心(无效),改全局描边类 .tag-outline(var(--brand) 文字+边框白底,随档变化)+ .tag-outline-muted 中性灰态;转出角标不变
2026-10-11 11:07:02 +08:00
chenxing d034320406 feat(验收微调轮): 状态徽标描边化+树卡300px+截断预留收窄+全应用/日志链接主题色
build / build (push) Successful in 2m3s
- group-user 状态列 Tag 加 border 描边(对齐「当前生效」徽标样式,弃实心块)
- 组树顶部操作钮图标 18→16px、内边距收窄,第5钮不再贴边
- main.scss UserBase 专项:tree-wrapper 左树卡统一 300px(撤 AppList 180px 局部覆写)
- GroupAuth ztree 节点名预留 90→36px(子组继承复选框悬停才注入,常态不该预留),消除有空间却截断成 ...
- 角色授权「全应用」徽标改描边主题色 tag-brand;AppModuleCheckTree 当前角色文字 var(--brand)、面板 min-height 320→0
- 三个日志详情弹窗「下载/在线预览」链接 #577afb/#3d5afe → var(--brand)/var(--brand-2)
- 注:本机 I/O 风暴(冷读 node_modules 停顿 30-45s)致 npm run build 门禁未跑通验证,改动均为样式/props 级
2026-10-11 10:46:49 +08:00
chenxing 3b0f29b0d1 feat(接入规范验收修订轮): 操作列收口text形态+通道撑满高度+主题色批量落地
build / build (push) Successful in 1m58s
- 操作列:group-user/操作日志/开放接口日志/推送日志四页手写 h('a') 迁 actionColumn({mode:"text"}),颜色归通道 var(--brand);推送日志「重试」改 disabledWhen 置灰保位
- group-user 状态列 h('Tag')/h('Badge') 字符串组件名改 import 真组件(Vue3 坑)
- 高度:日志三页根节点改 page-wrapper yh-fill;TablePageAlovaNew 对齐 flow-center 同构版(.table-region + useElementHeight 实测 + 页长记忆);RoleAuthzList 转 tree-wrapper 骨架(外边界/间隙 16)
- 主题色:当前学校/当前组/当前角色、应用管理左类型面板、角色管理卡片操作位硬编码蓝改 var(--brand) 系令牌
- 顶栏房子图标钮删除(§4:首页入口收敛面包屑「首页」)
- 含 2026-10-10 试点未提交件:tableActions.ts 七函数、main.scss .op-cell、AppList/ModuleManage/RoleUserManage 迁移
2026-10-10 23:39:32 +08:00
chenxing 5c7d5d68e4 revert(顶栏): 移除 LOGO 文字显示(用户反馈子应用不展示,仅门户展示)(LOGO文字与主题JSON化增补)
build / build (push) Failing after 15s
2026-10-09 12:29:36 +08:00
chenxing a2aa4f925c fix(顶栏): LOGO 文字字号对齐应用名层级(19px/700)(LOGO文字与主题JSON化增补)
build / build (push) Failing after 1m2s
2026-10-09 11:56:43 +08:00
chenxing 2dec060b91 feat(顶栏): LOGO 徽标旁显示平台文字(yh-logo-text 键接入)(LOGO文字与主题JSON化)
build / build (push) Successful in 3m13s
2026-10-09 10:36:10 +08:00
chenxing c12628f051 外观偏好异步补齐C:顶栏新增云海 logo 徽标 + 接平台键 yh-logo-url + sync 件更新
build / build (push) Failing after 10s
- header-container 首位插入 .logo-badge(结构与 Notice 对齐,皮肤由通道 base.layout.css 提供);拷贝平台 yunhai-logo.svg 资产兜底
- localStorage["yh-logo-url"] 优先,@error 回落兜底;mounted 读一次 + 监听 yh-appearance-updated 即时切换
- sync 件:UserAvatar.vue 监听 yh-appearance-updated 重读键(YschTheme d9db725 分发)
2026-10-08 18:52:31 +08:00
39 changed files with 4076 additions and 343 deletions
+31 -8
View File
@@ -19,7 +19,22 @@ jobs:
options: --network infra-infra-net options: --network infra-infra-net
steps: steps:
- name: 安装 git / curl / zip(alpine) - name: 安装 git / curl / zip(alpine)
run: apk add --no-cache git curl zip run: |
set -e
# runner 容器偶发拉不到公网 apk 源(dl-cdn.alpinelinux.org 报 Permission denied 并卡满 60s×2),
# 这里先按可达性挑一个能通的镜像源再装包,避免整条流水线卡死在第一步、后续步骤全被 skip
chosen=""
for host in mirrors.aliyun.com mirrors.huaweicloud.com mirrors.cloud.tencent.com dl-cdn.alpinelinux.org; do
if wget -q -T 6 -O /dev/null "https://${host}/alpine/v3.20/main/x86_64/APKINDEX.tar.gz"; then
chosen="${host}"
break
fi
echo "apk 源不可达: ${host}"
done
if [ -z "${chosen}" ]; then echo "所有 apk 源均不可达,请检查 runner 容器网络/代理"; exit 1; fi
echo "使用 apk 源: ${chosen}"
sed -i "s#dl-cdn.alpinelinux.org#${chosen}#g" /etc/apk/repositories
apk add --no-cache git curl zip
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库 # 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库) - name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
@@ -38,21 +53,29 @@ jobs:
- name: 构建前端(node 容器) - name: 构建前端(node 容器)
run: | run: |
set -e set -e
docker rm -f frontbuild 2>/dev/null || true # 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
docker run -d --name frontbuild -w /build node:20 sleep 3600 # 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
docker cp "${PWD}/." frontbuild:/build/ # 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
docker exec frontbuild sh -c ' # GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="frontbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
docker run -d --name "$BUILD_CONTAINER" -w /build node:20 sleep 3600
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
docker exec "$BUILD_CONTAINER" sh -c '
set -e set -e
cd /build cd /build
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
# 国内镜像源:内网直连 registry.npmjs.org 跨境极慢(实测 npm install 12 分 46 秒 / 754 包)
npm config set registry https://registry.npmmirror.com
npm ci npm ci
npm run build npm run build
' '
echo "=== dist 产物 ===" echo "=== dist 产物 ==="
docker exec frontbuild sh -c 'ls -la /build/dist/ | head -20' docker exec "$BUILD_CONTAINER" sh -c 'ls -la /build/dist/ | head -20'
# 从容器 cp dist 到宿主工作目录 # 从容器 cp dist 到宿主工作目录
docker cp frontbuild:/build/dist ./dist docker cp "$BUILD_CONTAINER":/build/dist ./dist
docker rm -f frontbuild docker rm -f "$BUILD_CONTAINER"
echo "BUILD CHECK OK" echo "BUILD CHECK OK"
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件) # 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

+20
View File
@@ -30,8 +30,28 @@ export default {
'SysGroup.move': ['POST', '/api/sysgroup/Move'], 'SysGroup.move': ['POST', '/api/sysgroup/Move'],
'SysGroup.sysGroupEdit': ['POST', '/api/sysgroup/Edit/{id}'], 'SysGroup.sysGroupEdit': ['POST', '/api/sysgroup/Edit/{id}'],
'SysGroup.sysGroupRemove': ['POST', '/api/sysgroup/Delete/{id}'], 'SysGroup.sysGroupRemove': ['POST', '/api/sysgroup/Delete/{id}'],
'SysGroup.disable': ['POST', '/api/sysgroup/Disable/{id}'],
'SysGroup.enable': ['POST', '/api/sysgroup/Enable/{id}'],
'SysGroup.setIncludeChildren': ['POST', '/api/sysgroup/SetIncludeChildren/{id}'],
'SysGroup.batchAdd': ['POST', '/api/sysgroup/BatchAdd'], 'SysGroup.batchAdd': ['POST', '/api/sysgroup/BatchAdd'],
'SysGroup.sysGroupAdd': ['POST', '/api/sysgroup/Add'], 'SysGroup.sysGroupAdd': ['POST', '/api/sysgroup/Add'],
'CustomDeptCategory.customDeptCategoryList': ['GET', '/api/deptcategory/List'],
'CustomDeptCategory.customDeptCategoryAdd': ['POST', '/api/deptcategory/Add'],
'CustomDeptCategory.customDeptCategoryEdit': ['POST', '/api/deptcategory/Edit/{id}'],
'CustomDeptCategory.customDeptCategorySort': ['POST', '/api/deptcategory/Sort'],
'CustomDeptCategory.customDeptCategoryDelete': ['POST', '/api/deptcategory/Delete/{id}'],
'CustomDept.customDeptList': ['GET', '/api/customdept/List'],
'CustomDept.customDeptAdd': ['POST', '/api/customdept/Add'],
'CustomDept.customDeptEdit': ['POST', '/api/customdept/Edit/{id}'],
'CustomDept.customDeptMove': ['POST', '/api/customdept/Move/{id}'],
'CustomDept.customDeptSort': ['POST', '/api/customdept/Sort'],
'CustomDept.customDeptDisable': ['POST', '/api/customdept/Disable/{id}'],
'CustomDept.customDeptEnable': ['POST', '/api/customdept/Enable/{id}'],
'CustomDept.customDeptDelete': ['POST', '/api/customdept/Delete/{id}'],
'CustomDept.customDeptMembers': ['GET', '/api/customdept/Members/{id}'],
'CustomDept.customDeptCandidateUsers': ['GET', '/api/customdept/CandidateUsers/{id}'],
'CustomDept.customDeptAddMembers': ['POST', '/api/customdept/AddMembers/{id}'],
'CustomDept.customDeptRemoveMembers': ['POST', '/api/customdept/RemoveMembers/{id}'],
'PushSubscription.test': ['POST', '/api/push/Test'], 'PushSubscription.test': ['POST', '/api/push/Test'],
'PushSubscription.retry': ['POST', '/api/push/Retry/{id}'], 'PushSubscription.retry': ['POST', '/api/push/Retry/{id}'],
'GroupAuthz.setSchoolApp': ['POST', '/api/groupauthz/SetSchoolApp'], 'GroupAuthz.setSchoolApp': ['POST', '/api/groupauthz/SetSchoolApp'],
+623
View File
@@ -148,6 +148,59 @@ export interface SysGroupRequest {
type: number; type: number;
sort?: number; sort?: number;
} }
export interface SysGroupIncludeChildrenRequest {
includeChildren: boolean;
}
export interface CustomDeptCategoryRequest {
schoolId?: number;
name: string;
}
export interface CustomDeptCategorySortRequest {
schoolId?: number;
ids: number[];
}
export interface CustomDeptCategoryVo {
id?: number;
schoolId?: number;
name?: string;
sortNo?: number;
/** 该分类下有效部门数;>0 时分类不允许删除 */
departmentCount?: number;
}
export interface CustomDepartmentRequest {
schoolId?: number;
categoryId?: number;
name: string;
}
export interface CustomDepartmentMoveRequest {
schoolId?: number;
categoryId: number;
}
export interface CustomDepartmentSortRequest {
schoolId?: number;
categoryId?: number;
ids: number[];
}
export interface CustomDepartmentMemberRequest {
schoolId?: number;
userIds: number[];
}
export interface CustomDepartmentMemberVo {
userId?: number;
account?: string;
name?: string;
}
export interface CustomDepartmentVo {
id?: number;
schoolId?: number;
categoryId?: number;
categoryName?: string;
name?: string;
sortNo?: number;
/** true=启用 / false=停用(后端 CUSTOM 侧统一输出 boolean) */
enabled?: boolean;
memberCount?: number;
}
export interface PushTestRequest { export interface PushTestRequest {
appId?: number; appId?: number;
interfacePath?: string; interfacePath?: string;
@@ -244,6 +297,14 @@ export interface SysGroupVo {
name?: string; name?: string;
type?: number; type?: number;
sort?: number; sort?: number;
/** 启停状态:0=启用 / 1=停用(存量 null 按启用处理) */
status?: number;
/** 包含子组:1=含下级部门 / 0=仅本级(仅学校节点有值) */
includeChildren?: number;
/** 直属成员数(部门表格「人员」列;与服务端 SysGroupVo 同步,api:gen 后一样保留) */
memberCount?: number;
/** 直属成员姓名预览(最多 50 个),完整名单走 /api/sysuser/List?groupId= */
memberNames?: string[];
children?: SysGroupVo[]; children?: SysGroupVo[];
} }
export interface AppItem { export interface AppItem {
@@ -1538,6 +1599,81 @@ declare global {
>( >(
config: Config config: Config
): Alova2Method<null, 'SysGroup.sysGroupRemove', Config>; ): Alova2Method<null, 'SysGroup.sysGroupRemove', Config>;
/**
* ---
*
* [POST]
*
* **path:** /api/sysgroup/Disable/{id}
*
* ---
*
* **Response**
* ```ts
* type Response = null
* ```
*/
disable<
Config extends Alova2MethodConfig<null> & {
pathParams: {
id: number;
};
}
>(
config: Config
): Alova2Method<null, 'SysGroup.disable', Config>;
/**
* ---
*
* [POST]
*
* **path:** /api/sysgroup/Enable/{id}
*
* ---
*
* **Response**
* ```ts
* type Response = null
* ```
*/
enable<
Config extends Alova2MethodConfig<null> & {
pathParams: {
id: number;
};
}
>(
config: Config
): Alova2Method<null, 'SysGroup.enable', Config>;
/**
* ---
*
* [POST]
*
* **path:** /api/sysgroup/SetIncludeChildren/{id}
*
* ---
*
* **RequestBody**
* ```ts
* type RequestBody = SysGroupIncludeChildrenRequest
* ```
*
* **Response**
* ```ts
* type Response = null
* ```
*/
setIncludeChildren<
Config extends Alova2MethodConfig<null> & {
pathParams: {
id: number;
};
data: SysGroupIncludeChildrenRequest;
}
>(
config: Config
): Alova2Method<null, 'SysGroup.setIncludeChildren', Config>;
/** /**
* --- * ---
* *
@@ -1705,6 +1841,493 @@ declare global {
config?: Config config?: Config
): Alova2Method<SysGroupVo[], 'SysGroup.getSchoolTree', Config>; ): Alova2Method<SysGroupVo[], 'SysGroup.getSchoolTree', Config>;
}; };
CustomDeptCategory: {
/**
* ---
*
* [GET]
*
* **path:** /api/deptcategory/List
*
* ---
*
* **Query Parameters**
* ```ts
* type QueryParameters = {
* schoolId?: number
* }
* ```
*
* ---
*
* **Response**
* ```ts
* type Response = Array<CustomDeptCategoryVo>
* ```
*/
customDeptCategoryList<
Config extends Alova2MethodConfig<CustomDeptCategoryVo[]> & {
params?: {
schoolId?: number;
};
}
>(
config: Config
): Alova2Method<CustomDeptCategoryVo[], 'CustomDeptCategory.customDeptCategoryList', Config>;
/**
* ---
*
* [POST]
*
* **path:** /api/deptcategory/Add
*
* ---
*
* **Request Body**
* ```ts
* type RequestBody = CustomDeptCategoryRequest
* ```
*
* ---
*
* **Response**
* ```ts
* type Response = CustomDeptCategoryVo
* ```
*/
customDeptCategoryAdd<
Config extends Alova2MethodConfig<CustomDeptCategoryVo> & {
data: CustomDeptCategoryRequest;
}
>(
config: Config
): Alova2Method<CustomDeptCategoryVo, 'CustomDeptCategory.customDeptCategoryAdd', Config>;
/**
* ---
*
* [POST]
*
* **path:** /api/deptcategory/Edit/{id}
*
* ---
*
* **Request Body**
* ```ts
* type RequestBody = CustomDeptCategoryRequest
* ```
*
* ---
*
* **Response**
* ```ts
* type Response = CustomDeptCategoryVo
* ```
*/
customDeptCategoryEdit<
Config extends Alova2MethodConfig<CustomDeptCategoryVo> & {
pathParams: {
id: number;
};
data: CustomDeptCategoryRequest;
}
>(
config: Config
): Alova2Method<CustomDeptCategoryVo, 'CustomDeptCategory.customDeptCategoryEdit', Config>;
/**
* ---
*
* [POST]
*
* **path:** /api/deptcategory/Sort
*
* ---
*
* **Request Body**
* ```ts
* type RequestBody = CustomDeptCategorySortRequest
* ```
*
* ---
*
* **Response**
* ```ts
* type Response = null
* ```
*/
customDeptCategorySort<
Config extends Alova2MethodConfig<null> & {
data: CustomDeptCategorySortRequest;
}
>(
config: Config
): Alova2Method<null, 'CustomDeptCategory.customDeptCategorySort', Config>;
/**
* ---
*
* [POST]
*
* **path:** /api/deptcategory/Delete/{id}
*
* ---
*
* **Response**
* ```ts
* type Response = null
* ```
*/
customDeptCategoryDelete<
Config extends Alova2MethodConfig<null> & {
pathParams: {
id: number;
};
}
>(
config: Config
): Alova2Method<null, 'CustomDeptCategory.customDeptCategoryDelete', Config>;
};
CustomDept: {
/**
* ---
*
* [GET]
*
* **path:** /api/customdept/List
*
* ---
*
* **Query Parameters**
* ```ts
* type QueryParameters = {
* categoryId: number
* schoolId?: number
* }
* ```
*
* ---
*
* **Response**
* ```ts
* type Response = Array<CustomDepartmentVo>
* ```
*/
customDeptList<
Config extends Alova2MethodConfig<CustomDepartmentVo[]> & {
params: {
categoryId: number;
schoolId?: number;
};
}
>(
config: Config
): Alova2Method<CustomDepartmentVo[], 'CustomDept.customDeptList', Config>;
/**
* ---
*
* [POST]
*
* **path:** /api/customdept/Add
*
* ---
*
* **Request Body**
* ```ts
* type RequestBody = CustomDepartmentRequest
* ```
*
* ---
*
* **Response**
* ```ts
* type Response = CustomDepartmentVo
* ```
*/
customDeptAdd<
Config extends Alova2MethodConfig<CustomDepartmentVo> & {
data: CustomDepartmentRequest;
}
>(
config: Config
): Alova2Method<CustomDepartmentVo, 'CustomDept.customDeptAdd', Config>;
/**
* ---
*
* [POST]
*
* **path:** /api/customdept/Edit/{id}
*
* ---
*
* **Request Body**
* ```ts
* type RequestBody = CustomDepartmentRequest
* ```
*
* ---
*
* **Response**
* ```ts
* type Response = CustomDepartmentVo
* ```
*/
customDeptEdit<
Config extends Alova2MethodConfig<CustomDepartmentVo> & {
pathParams: {
id: number;
};
data: CustomDepartmentRequest;
}
>(
config: Config
): Alova2Method<CustomDepartmentVo, 'CustomDept.customDeptEdit', Config>;
/**
* ---
*
* [POST]
*
* **path:** /api/customdept/Move/{id}
*
* ---
*
* **Request Body**
* ```ts
* type RequestBody = CustomDepartmentMoveRequest
* ```
*
* ---
*
* **Response**
* ```ts
* type Response = CustomDepartmentVo
* ```
*/
customDeptMove<
Config extends Alova2MethodConfig<CustomDepartmentVo> & {
pathParams: {
id: number;
};
data: CustomDepartmentMoveRequest;
}
>(
config: Config
): Alova2Method<CustomDepartmentVo, 'CustomDept.customDeptMove', Config>;
/**
* ---
*
* [POST]
*
* **path:** /api/customdept/Sort
*
* ---
*
* **Request Body**
* ```ts
* type RequestBody = CustomDepartmentSortRequest
* ```
*
* ---
*
* **Response**
* ```ts
* type Response = null
* ```
*/
customDeptSort<
Config extends Alova2MethodConfig<null> & {
data: CustomDepartmentSortRequest;
}
>(
config: Config
): Alova2Method<null, 'CustomDept.customDeptSort', Config>;
/**
* ---
*
* [POST]
*
* **path:** /api/customdept/Disable/{id}
*
* ---
*
* **Response**
* ```ts
* type Response = null
* ```
*/
customDeptDisable<
Config extends Alova2MethodConfig<null> & {
pathParams: {
id: number;
};
}
>(
config: Config
): Alova2Method<null, 'CustomDept.customDeptDisable', Config>;
/**
* ---
*
* [POST]
*
* **path:** /api/customdept/Enable/{id}
*
* ---
*
* **Response**
* ```ts
* type Response = null
* ```
*/
customDeptEnable<
Config extends Alova2MethodConfig<null> & {
pathParams: {
id: number;
};
}
>(
config: Config
): Alova2Method<null, 'CustomDept.customDeptEnable', Config>;
/**
* ---
*
* [POST]
*
* **path:** /api/customdept/Delete/{id}
*
* ---
*
* **Response**
* ```ts
* type Response = null
* ```
*/
customDeptDelete<
Config extends Alova2MethodConfig<null> & {
pathParams: {
id: number;
};
}
>(
config: Config
): Alova2Method<null, 'CustomDept.customDeptDelete', Config>;
/**
* ---
*
* [GET]
*
* **path:** /api/customdept/Members/{id}
*
* ---
*
* **Response**
* ```ts
* type Response = Array<CustomDepartmentMemberVo>
* ```
*/
customDeptMembers<
Config extends Alova2MethodConfig<CustomDepartmentMemberVo[]> & {
pathParams: {
id: number;
};
}
>(
config: Config
): Alova2Method<CustomDepartmentMemberVo[], 'CustomDept.customDeptMembers', Config>;
/**
* ---
*
* [GET]
*
* **path:** /api/customdept/CandidateUsers/{id}
*
* ---
*
* **Query Parameters**
* ```ts
* type QueryParameters = {
* keyword?: string
* }
* ```
*
* ---
*
* **Response**
* ```ts
* type Response = Array<CustomDepartmentMemberVo>
* ```
*/
customDeptCandidateUsers<
Config extends Alova2MethodConfig<CustomDepartmentMemberVo[]> & {
pathParams: {
id: number;
};
params?: {
keyword?: string;
};
}
>(
config: Config
): Alova2Method<CustomDepartmentMemberVo[], 'CustomDept.customDeptCandidateUsers', Config>;
/**
* ---
*
* [POST]
*
* **path:** /api/customdept/AddMembers/{id}
*
* ---
*
* **Request Body**
* ```ts
* type RequestBody = CustomDepartmentMemberRequest
* ```
*
* ---
*
* **Response**
* ```ts
* type Response = null
* ```
*/
customDeptAddMembers<
Config extends Alova2MethodConfig<null> & {
pathParams: {
id: number;
};
data: CustomDepartmentMemberRequest;
}
>(
config: Config
): Alova2Method<null, 'CustomDept.customDeptAddMembers', Config>;
/**
* ---
*
* [POST]
*
* **path:** /api/customdept/RemoveMembers/{id}
*
* ---
*
* **Request Body**
* ```ts
* type RequestBody = CustomDepartmentMemberRequest
* ```
*
* ---
*
* **Response**
* ```ts
* type Response = null
* ```
*/
customDeptRemoveMembers<
Config extends Alova2MethodConfig<null> & {
pathParams: {
id: number;
};
data: CustomDepartmentMemberRequest;
}
>(
config: Config
): Alova2Method<null, 'CustomDept.customDeptRemoveMembers', Config>;
};
PushSubscription: { PushSubscription: {
/** /**
* --- * ---
+15
View File
@@ -13,6 +13,9 @@ import { UserRoleEnum } from "@/types";
* 2. 应用管理 —— S+C(局端不开放:局端的应用动作是「开通」,应用维护是平台级) * 2. 应用管理 —— S+C(局端不开放:局端的应用动作是「开通」,应用维护是平台级)
* 3. 权限管理 —— 父目录:应用开通(S+B) / 组授权 / 角色授权 / 权限一览(S+B+C) * 3. 权限管理 —— 父目录:应用开通(S+B) / 组授权 / 角色授权 / 权限一览(S+B+C)
* 4. 日志管理 —— 仅超管(父目录) * 4. 日志管理 —— 仅超管(父目录)
* 5. 基础设置 —— 非业务域的基础配置合集(当前:部门管理),后续字典/参数/模板等杂项归此
*
* 菜单不渲染图标:本工程侧边栏只输出文字,节点无需 icon 字段。
* *
* 每个节点带唯一 id;子节点的 pid 由 layout.vue 的 flaten() 扁平化时 * 每个节点带唯一 id;子节点的 pid 由 layout.vue 的 flaten() 扁平化时
* 依据父节点 id 自动推导,用于「高亮自身 + 面包屑展示父级」联动。 * 依据父节点 id 自动推导,用于「高亮自身 + 面包屑展示父级」联动。
@@ -64,6 +67,18 @@ export function getMenuList(userRole?: UserRoleEnum): Promise<IModule[]> {
{ id: 54, modId: 1112, name: "推送日志", page: "/push-log", roles: [UserRoleEnum.SuperAdmin] }, { id: 54, modId: 1112, name: "推送日志", page: "/push-log", roles: [UserRoleEnum.SuperAdmin] },
], ],
}, },
// 5. 基础设置(父目录):非业务域的基础配置杂项合集,部门管理从「权限管理」迁入
{
id: 6,
modId: 1115,
name: "基础设置",
page: "",
childState: true,
roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau, UserRoleEnum.School],
children: [
{ id: 61, modId: 1106, name: "部门管理", page: "/dept-manage", roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau, UserRoleEnum.School] },
],
},
]; ];
return Promise.resolve(filterMenuByRole(menu, userRole)); return Promise.resolve(filterMenuByRole(menu, userRole));
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path fill="#4aaff0" d="M512,585 C415,455 330,398 215,395 C185,395 160,415 160,450 L160,770 C160,815 190,838 240,845 C340,856 430,872 512,888 C594,872 684,856 784,845 C834,838 864,815 864,770 L864,450 C864,415 839,395 809,395 C694,398 609,455 512,585 Z"/>
<path fill="#4aaff0" d="M218,312 C335,292 462,352 514,462 C452,404 334,354 218,378 Z"/>
<circle fill="#4aaff0" cx="218" cy="345" r="33"/>
<path fill="#80d976" d="M508,462 C455,310 525,145 700,105 C758,250 668,425 508,462 Z"/>
<path fill="none" stroke="#4aaff0" stroke-width="44" stroke-linecap="round" d="M210,935 Q285.5,881 361,935 T512,935 T663,935 T814,935"/>
</svg>

After

Width:  |  Height:  |  Size: 701 B

+73 -5
View File
@@ -98,20 +98,88 @@ h4.subtitle {
} }
} }
/* 表格加载(规范 §4 表格加载行):图形=通道 .ysch-spin(随 --brand),本地零图形资产 */
.table-spin-load { .table-spin-load {
position: relative;
width: 60px; width: 60px;
height: 90px; height: 90px;
padding-top: 60px; padding-top: 60px;
background-image: url(../image/loading.gif);
background-size: 60px 60px;
background-position: top center;
background-repeat: no-repeat;
border: none; border: none;
color: #577afb; color: var(--brand);
margin: 0 auto; margin: 0 auto;
text-align: center;
}
.table-spin-load .ysch-spin {
position: absolute;
left: 50%;
top: 0;
width: 60px;
height: 60px;
margin-left: -30px;
font-size: 60px; /* .ysch-spin 尺寸=1em,60px 图形用字号给 */
} }
.ivu-table-cell-tree { .ivu-table-cell-tree {
margin-right: 4px; margin-right: 4px;
margin-bottom: 2px; margin-bottom: 2px;
} }
/* ================= 表格操作列(utils/tableActions.ts)=================
.op-cell:flex 横排容器——把 iView Tooltip/Dropdown 的块级包装层降级为 flex item,
强制横排(根因:.operating-btn 在通道基线 display:block,.ivu-tooltip 包装层默认 block,
逐钮竖排;不要再在 cell 上动 display,会牵连其它列)。
先落工程内,四端同构后此段上提 YschTheme 主题层。 */
.op-cell {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
// 链间距统一由容器 gap 管理,退役通道基线的 padding-right:10px
.operating-link {
padding-right: 0;
white-space: nowrap;
}
}
/* 「更多▾」触发器(Dropdown transfer 后菜单挂 body,样式必须全局) */
.op-more {
.op-more-label {
display: inline-flex;
align-items: center;
gap: 2px;
}
.op-more-arrow {
font-size: 12px;
}
}
/* 「更多」菜单内危险项(红色 + hover 红底,与 text-error 同源 $color-error) */
.ivu-dropdown-item.op-item-danger {
color: $color-error !important;
&:hover {
color: $color-error !important;
background-color: rgba(232, 104, 74, 0.08);
}
}
/* UserBase 专项(不上收通道,勿写入接入规范):左侧树卡统一 300px——
通道默认 240 装不下组树操作钮与节点名,本端树页多、统一加宽 */
.base-wrap-main .tree-wrapper .tree-container {
width: 300px;
}
/* 描边徽标(对齐 UserHome「当前生效」样式):view-ui-plus 预设色 Tag 的 border prop
渲染仍是实心块(无效 prop),改自定义类挂主题令牌——主态=brand 描边,muted=中性灰描边 */
.tag-outline {
color: var(--brand) !important;
border: 1px solid var(--brand) !important;
background: #fff !important;
}
.tag-outline.tag-outline-muted {
color: #999 !important;
border: 1px solid #dcdee2 !important;
}
+6
View File
@@ -215,6 +215,11 @@ function getNodeById(id: number) {
return refMyTree.value?.getNodeById(id); return refMyTree.value?.getNodeById(id);
} }
/** 在指定节点后追加自定义控件(见 MyTree.attachNodeControl) */
function attachNodeControl(id: number, className: string, html: string) {
return refMyTree.value?.attachNodeControl(id, className, html);
}
function getCheckedNodes() { function getCheckedNodes() {
return refMyTree.value?.getCheckedNodes() || []; return refMyTree.value?.getCheckedNodes() || [];
} }
@@ -257,6 +262,7 @@ defineExpose({
removeNode, removeNode,
renameNode, renameNode,
getNodeById, getNodeById,
attachNodeControl,
getCheckedNodes, getCheckedNodes,
checkNodeById, checkNodeById,
addNote, addNote,
+27
View File
@@ -222,6 +222,33 @@ export default {
if (on) a.addClass('ztree-search-highlight'); if (on) a.addClass('ztree-search-highlight');
else a.removeClass('ztree-search-highlight'); else a.removeClass('ztree-search-highlight');
}, },
/**
* 给指定节点追加自定义 DOM 控件(渲染在节点文字之后)。
* 用途:在 zTree 节点行尾挂业务控件(如开关),不污染 node.name、
* 也不触发节点选中(zTree 的 onClick 绑定在节点 a 标签上,控件在其外部)。
* 树数据重载(initTree)后需重新挂载。
* @param {*} id 节点 id
* @param {string} className 控件容器类名(同时作为去重标识)
* @param {string} html 控件 HTML
* @returns {HTMLElement|null} 控件元素,失败返回 null
*/
attachNodeControl(id, className, html) {
const _domId = this.domId;
const treeObj = $.fn.zTree.getZTreeObj(_domId);
if (!treeObj || !id || !className) return null;
const node = treeObj.getNodesByFilter(n => n.id == id, true);
if (!node) return null;
const li = document.getElementById(node.tId);
if (!li) return null;
li.querySelectorAll("." + className).forEach(el => el.remove());
const box = document.createElement("span");
box.className = className;
box.innerHTML = html;
const a = document.getElementById(node.tId + "_a");
if (a && a.parentNode) a.parentNode.insertBefore(box, a.nextSibling);
else li.appendChild(box);
return box;
},
clearHighlight() { clearHighlight() {
(this.highlightNodes || []).forEach(n => this.setHighlight(n, false)); (this.highlightNodes || []).forEach(n => this.setHighlight(n, false));
this.highlightNodes = []; this.highlightNodes = [];
+1 -1
View File
@@ -3,7 +3,7 @@
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据" <Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
class="link-table table-bottom-box" @on-selection-change="selectChange"> class="link-table table-bottom-box" @on-selection-change="selectChange">
<template #loading> <template #loading>
<div class="table-spin-load">加载中</div> <div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
</template> </template>
</Table> </Table>
+32 -15
View File
@@ -1,16 +1,18 @@
<template> <template>
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据" <div class="table-region" ref="regionRef">
class="link-table table-bottom-box" @on-selection-change="selectChange"> <Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
<template #loading> class="link-table table-bottom-box" @on-selection-change="selectChange">
<div class="table-spin-load">加载中</div> <template #loading>
</template> <div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps"> </template>
<slot :name="slotName" v-bind="slotProps" /> <template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
</template> <slot :name="slotName" v-bind="slotProps" />
</Table> </template>
</Table>
</div>
<div class="page-panel"> <div class="page-panel">
<div class="fl align-middle"> <div class="fl ml-15 align-middle">
<slot :checkList="selectList" /> <slot :checkList="selectList" />
</div> </div>
@@ -21,8 +23,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, useSlots } from 'vue' import { computed, ref, useSlots, watch } from 'vue'
import Utils from '@/utils' import { useElementHeight } from '@/utils/hook/useElementHeight'
import { OperatorEnum } from '@/types' import { OperatorEnum } from '@/types'
interface Props { interface Props {
@@ -44,7 +46,10 @@ const emit = defineEmits<{
}>() }>()
const slots = useSlots() const slots = useSlots()
const tableH = ref(props.tableHeight ?? Utils.useTableHeight()) const regionRef = ref<HTMLElement | null>(null)
const measuredH = useElementHeight(regionRef)
// 显式传 tableHeight 则尊重之;否则实测 .table-region(flex:1)高度喂固定表头,首帧未测出前用 undefined(auto)
const tableH = computed(() => props.tableHeight ?? (measuredH.value || undefined))
const tableSlotNames = computed(() => Object.keys(slots).filter(name => !['default', 'loading'].includes(name))) const tableSlotNames = computed(() => Object.keys(slots).filter(name => !['default', 'loading'].includes(name)))
const selectList = ref<number[]>([]) const selectList = ref<number[]>([])
@@ -68,18 +73,30 @@ const {
total: res => props.formatResponse(res).total, total: res => props.formatResponse(res).total,
data: res => props.formatResponse(res).list, data: res => props.formatResponse(res).list,
initialPage: 1, initialPage: 1,
initialPageSize: 10, // 页长记忆(bsch 应用页规范 §3:customPageCount localStorage)
initialPageSize: parseInt(localStorage.getItem('customPageCount') || '10') || 10,
immediate: props.autoLoad, immediate: props.autoLoad,
} }
) )
// 数据重载后清空勾选,避免批量操作用到旧选中行
watch(resp, () => {
if (selectList.value.length) {
selectList.value = []
emit('selection-change', selectList.value)
}
})
const list = computed(() => { const list = computed(() => {
if (!resp.value) return [] if (!resp.value) return []
return props.dataFilter ? props.dataFilter(resp.value) : resp.value return props.dataFilter ? props.dataFilter(resp.value) : resp.value
}) })
const onPageChange = (p: number) => (page.value = p) const onPageChange = (p: number) => (page.value = p)
const onSizeChange = (s: number) => (pageSize.value = s) const onSizeChange = (s: number) => {
pageSize.value = s
localStorage.setItem('customPageCount', String(s))
}
function refreshData() { function refreshData() {
page.value = 1 page.value = 1
+11 -2
View File
@@ -4,7 +4,7 @@
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源, 取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。 不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */ 根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
import { computed } from "vue"; import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar"; import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
const props = withDefaults( const props = withDefaults(
@@ -22,7 +22,16 @@ const props = withDefaults(
); );
const text = computed(() => (props.src ? "" : pickAvatarText(props.name))); const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
const style = computed(() => resolveAvatarStyle()); /* 外观偏好异步补齐(yh-theme-init 在挂载后拉服务端真源收敛 localStorage 键)会在
document 上派发 yh-appearance-updated;tick 自增让 style 依赖失效重读键,三态即时切换(无需刷新)。 */
const appearanceTick = ref(0);
const onAppearanceUpdated = () => { appearanceTick.value += 1; };
onMounted(() => document.addEventListener("yh-appearance-updated", onAppearanceUpdated));
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", onAppearanceUpdated));
const style = computed(() => {
appearanceTick.value;
return resolveAvatarStyle();
});
/** 首字态 = style==="text" 且能取到字(契约 §10.2:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */ /** 首字态 = style==="text" 且能取到字(契约 §10.2:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */
const showText = computed(() => style.value === "text" && !!text.value); const showText = computed(() => style.value === "text" && !!text.value);
+25 -14
View File
@@ -1,11 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, inject, onBeforeMount, watch, toRefs, nextTick, computed } from "vue"; import { ref, inject, onBeforeMount, onBeforeUnmount, onMounted, watch, toRefs, nextTick, computed } from "vue";
import { LayoutData } from "@/types/Basics"; import { LayoutData } from "@/types/Basics";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import useCurrentInstance from "@/utils/hook/useCurrentInstance"; import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import SvgIcon from "@/components/SvgIcon.vue"; import SvgIcon from "@/components/SvgIcon.vue";
import UserAvatar from "@/components/UserAvatar.vue"; import UserAvatar from "@/components/UserAvatar.vue";
import defaultLogo from "@/assets/image/yunhai-logo.svg";
let props = defineProps<{ layoutData: LayoutData }>(); let props = defineProps<{ layoutData: LayoutData }>();
const reload: Function = inject("reload") || new Function("console.error('empty')"); const reload: Function = inject("reload") || new Function("console.error('empty')");
@@ -13,6 +14,27 @@ const appPanel = ref();
let route = useRoute(); let route = useRoute();
const router = useRouter(); const router = useRouter();
// 顶栏 Logo:localStorage["yh-logo-url"](平台键,yh-theme-init 异步补齐段写入)优先,本仓资产兜底——绝无空 src/裂图。
// 刷新:mounted 读一次 + 监听 yh-appearance-updated(服务端偏好到达后即时切换,无需刷新页面)。
const logoUrl = ref<string>(defaultLogo);
const readLogoUrl = () => {
let v: string | null = null;
try {
v = localStorage.getItem("yh-logo-url");
} catch {
v = null;
}
logoUrl.value = v || defaultLogo;
};
const onLogoError = () => {
if (logoUrl.value !== defaultLogo) logoUrl.value = defaultLogo;
};
onMounted(() => {
readLogoUrl();
document.addEventListener("yh-appearance-updated", readLogoUrl);
});
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", readLogoUrl));
const isPanel = ref(false), const isPanel = ref(false),
currPath = ref<string>(""), currPath = ref<string>(""),
navPath = ref<any[]>([]); navPath = ref<any[]>([]);
@@ -167,11 +189,8 @@ function linkHomePage() {
<template> <template>
<div class="layout-wrap"> <div class="layout-wrap">
<header class="header-container fix"> <header class="header-container fix">
<div class="fl align-middle icon-box"> <!-- 云海 logo 徽标(结构与 Notice layout 对齐,皮肤见通道 base.layout.css .logo-badge) -->
<div title="首页" class="icon-btn home-btn" @click="linkHomePage"> <span class="logo-badge fl"><img :src="logoUrl" alt="云海" @error="onLogoError" /></span>
<SvgIcon name="shouye" :size="22" color="#fff" />
</div>
</div>
<div class="delimiter fl align-middle"></div> <div class="delimiter fl align-middle"></div>
<h4 class="fl align-middle">{{ appName }}</h4> <h4 class="fl align-middle">{{ appName }}</h4>
<div class="school-bg"></div> <div class="school-bg"></div>
@@ -271,12 +290,4 @@ function linkHomePage() {
.header-aside .logout svg { .header-aside .logout svg {
display: block; display: block;
} }
.home-btn {
display: flex;
align-items: center;
justify-content: center;
}
.home-btn svg {
display: block;
}
</style> </style>
+10
View File
@@ -12,6 +12,7 @@ import PermissionView from "@/views/PermissionView.vue";
import OperationLogList from "@/views/oplog/OperationLogList.vue"; import OperationLogList from "@/views/oplog/OperationLogList.vue";
import OapiLogList from "@/views/oapilog/OapiLogList.vue"; import OapiLogList from "@/views/oapilog/OapiLogList.vue";
import PushLog from "@/views/push/PushLog.vue"; import PushLog from "@/views/push/PushLog.vue";
import DeptManage from "@/views/dept-manage/index.vue";
import NoAuth from "@/views/NoAuth.vue"; import NoAuth from "@/views/NoAuth.vue";
// 管理端四级访问控制:meta.roles 为空/未声明 = 登录即可访问 // 管理端四级访问控制:meta.roles 为空/未声明 = 登录即可访问
@@ -105,6 +106,15 @@ const routes: RouteRecordRaw[] = [
component: PermissionView, component: PermissionView,
meta: { title: "权限一览", icon: "icon-list", modId: 1105, roles: ALL_ADMIN }, meta: { title: "权限一览", icon: "icon-list", modId: 1105, roles: ALL_ADMIN },
}, },
// ===== 部门管理(2026-10-20 部门线 P0 最小批,2026-10-21 迁入「基础设置」分组)=====
// 原挂「权限管理」(modId 1108);部门属于组织基础配置而非权限,改挂「基础设置」(modId 1115)
// 无删除/成员/职务/新建入口——建部门=建组回「组,用户管理」
{
path: "/dept-manage",
name: "DeptManage",
component: DeptManage,
meta: { title: "部门管理", icon: "icon-list", modId: 1106, parentId: 1115, roles: ALL_ADMIN },
},
{ {
path: "/push-log", path: "/push-log",
name: "PushLog", name: "PushLog",
+36
View File
@@ -0,0 +1,36 @@
import { ref, onMounted, onBeforeUnmount, watch, type Ref } from "vue";
/**
* 实测某容器元素的可用高度(px),随尺寸变化实时更新。
*
* 用途:表格页把 <Table> 放进 flex:1 的 .table-region 里,用本组合式量出该区域
* 的真实高度喂给 view-ui 的 `:height`(固定表头需要一个数值高度),从而彻底替代
* 旧的 useTableHeight()「视口高 - 一堆写死常量」的算法——加/删工具条、改顶栏高
* 都不再需要同步改任何魔法数字。
*
* @param el 目标容器 ref(模板里 ref 绑定的那个元素)
* @returns 响应式高度(px);挂载测量前为 0,调用方可 `:height="h || undefined"` 兜底
*/
export function useElementHeight(el: Ref<HTMLElement | null>): Ref<number> {
const height = ref(0);
let ro: ResizeObserver | null = null;
const measure = () => {
if (el.value) height.value = Math.floor(el.value.clientHeight);
};
const observe = () => {
ro?.disconnect();
if (!el.value || typeof ResizeObserver === "undefined") return;
measure();
ro = new ResizeObserver(measure);
ro.observe(el.value);
};
onMounted(observe);
// v-if / v-show 切换导致容器重建时,重新挂观察
watch(el, observe);
onBeforeUnmount(() => ro?.disconnect());
return height;
}
+12 -33
View File
@@ -1,5 +1,5 @@
import { IObj, UseTableSetting } from "@/types/Basics"; import { IObj, UseTableSetting } from "@/types/Basics";
import { resolveComponent } from "vue"; import { actionIcon, actionText } from "@/utils/tableActions";
export default class Utils { export default class Utils {
/** /**
* 格式化日期 * 格式化日期
@@ -90,7 +90,8 @@ export default class Utils {
} }
/** /**
* iview Tooltip封装 * iview Tooltip封装(旧 API,薄封装转调 tableActions.actionIcon,签名不变)
* 2026-10-10 起 D1 修正:禁用态 onClick 置 null(原先禁用仍可点)。
*/ */
public static operatingHandle( public static operatingHandle(
h: any, h: any,
@@ -99,30 +100,10 @@ export default class Utils {
callback: Function, callback: Function,
disabled: boolean = false, disabled: boolean = false,
) { ) {
if (disabled) iconClass += " disabled"; return actionIcon(h, { key: info, label: info, icon: iconClass, on: () => callback(), disabled });
return h(
resolveComponent("Tooltip"),
{
content: info,
placement: "bottom",
transfer: true,
},
{
default: () => [
h(
"div",
{
class: `operating-btn iconfont ${iconClass}`,
onClick: callback,
},
null,
),
],
},
);
} }
/** 旧 API,薄封装转调 tableActions.actionText(签名不变;第三参仅识别 text-error → danger) */
public static operatingTextHandle( public static operatingTextHandle(
h: any, h: any,
info: string, info: string,
@@ -130,15 +111,13 @@ export default class Utils {
callback: Function, callback: Function,
disabled: boolean = false, disabled: boolean = false,
) { ) {
if (disabled) className += " disabled"; return actionText(h, {
return h( key: info,
"div", label: info,
{ danger: (className || "").includes("text-error"),
class: `operating-link ${className}`, on: () => callback(),
onClick: disabled ? null : callback, disabled,
}, });
info,
);
} }
/** /**
+160
View File
@@ -0,0 +1,160 @@
/**
* 表格操作列统一原子(方案:outputs/table-action-buttons/操作列统一方案-20261010.md)
*
* 三条变化轴各自独立,每加一种形态 = 加 1 个渲染器 + RENDERERS 表加 1 行,不动既有分支:
* 轴1 形态 actionIcon / actionText / actionMore —— 只画节点,零策略
* 轴2 动作集 normalizeActions —— 纯数据:when 过滤 / disabled 归一 / danger 沉底
* 轴3 溢出 splitActions —— 纯函数:直出与「更多」分档,零 DOM 可单测
* 组装 actionCell / actionColumn —— 唯一懂 DOM 结构与表格列
*
* 形态由页面【显式声明】(mode: "icon" | "text",缺省 text),不按动作数自动切换——
* 工程间风格区分是既定口径(Notice=图标钮 / UserBase=文字链),各自恒定不漂移。
* 「更多▾」只是长尾收纳(动作数 ≥ maxInline+2 时启用),不改变形态、不分档。
* 菜单项纯文字不带图标(动作一多找不到贴切图标,文字更准);transfer 到 body,
* 避免 fixed/sticky 操作列裁剪与层叠上下文遮盖(原型已实证)。
*/
import { h, resolveComponent } from "vue";
export interface ActionItem {
/** 稳定标识:溢出分桶按 key 去重,必填 */
key: string;
label: string;
/** 点击回调,normalizeActions 阶段闭包绑定行数据 */
on: (row: any) => void;
/** iconfont 类名(仅 mode:"icon" 时消费;通道 icons.css 全集见 YschTheme) */
icon?: string;
/** 危险动作:文字转红(text-error)、恒沉底、「更多」内分隔线隔离 */
danger?: boolean;
/** 返回 false → 该行不渲染此动作(决定「有没有」) */
when?: (row: any) => boolean;
/** 直接置灰 */
disabled?: boolean;
/** 按行判定置灰(决定「能不能点」),disabled 显式值优先 */
disabledWhen?: (row: any) => boolean;
}
export interface ActionCellOpts {
/** 形态显式声明:icon=图标圆钮 / text=文字链,缺省 text;不按动作数自动切换 */
mode?: "icon" | "text";
/** 超额动作收进「更多」,默认开(防 5+ 动作页面忘传爆宽) */
collapsible?: boolean;
/** 直出上限,默认 2;n ≤ maxInline+1 时全直出(3 个动作不进更多) */
maxInline?: number;
/** 列宽;缺省 icon = n*40+40 / text = 180 */
width?: number;
align?: "center" | "left" | "right";
/** 缺省不固定列(与存量页面一致);需要吸右显式传 "right" */
fixed?: "right" | "left" | false;
}
/* ============ 轴 1:渲染器(只画节点,零策略) ============ */
/** 图标圆钮:Tooltip > div.operating-btn(沿用通道既有 class,主题层零改动) */
export function actionIcon(h: any, item: ActionItem) {
return h(
resolveComponent("Tooltip"),
{ content: item.label, placement: "bottom", transfer: true },
{
default: () => [
h("div", {
class: `operating-btn iconfont ${item.icon}${item.disabled ? " disabled" : ""}`,
onClick: item.disabled ? null : item.on,
}),
],
},
);
}
/** 文字链:danger 输出既有 text-error 类对接主题红色 */
export function actionText(h: any, item: ActionItem) {
return h(
"div",
{
class: `operating-link${item.danger ? " text-error" : ""}${item.disabled ? " disabled" : ""}`,
onClick: item.disabled ? null : item.on,
},
item.label,
);
}
/** 「更多▾」+ 下拉菜单:iView Dropdown transfer 模式,菜单挂 body */
export function actionMore(h: any, items: ActionItem[]) {
return h(
resolveComponent("Dropdown"),
{ class: "op-more", placement: "bottom-end", transfer: true },
{
default: () => [
h("div", { class: "operating-link op-more-label" }, [
"更多",
h(resolveComponent("Icon"), { type: "ios-arrow-down", class: "op-more-arrow" }),
]),
],
list: () => [
h(resolveComponent("DropdownMenu"), {}, {
default: () => items.map((it) =>
h(
resolveComponent("DropdownItem"),
{
key: it.key,
divided: !!(it.danger && items.length > 1),
disabled: !!it.disabled,
class: it.danger ? "op-item-danger" : "",
onClick: () => it.on(),
},
{ default: () => it.label },
),
),
}),
],
},
);
}
/* ============ 轴 2:归一化(纯数据) ============ */
export function normalizeActions(items: ActionItem[], row: any): ActionItem[] {
return items
.filter((it) => (it.when ? it.when(row) : true))
.map((it) => ({
...it,
disabled: it.disabled ?? (it.disabledWhen ? it.disabledWhen(row) : false),
on: () => it.on(row),
}))
.sort((a, b) => Number(!!a.danger) - Number(!!b.danger)); // danger 恒沉底(稳定排序)
}
/* ============ 轴 3:溢出策略(纯函数,可单测、零 DOM) ============ */
export function splitActions(items: ActionItem[], maxInline = 2): { inline: ActionItem[]; overflow: ActionItem[] } {
if (items.length <= maxInline + 1) return { inline: items, overflow: [] };
const inline = items.filter((it) => !it.danger).slice(0, maxInline);
const inlineKeys = new Set(inline.map((it) => it.key));
return { inline, overflow: items.filter((it) => !inlineKeys.has(it.key)) };
}
/* ============ 组装层 ============ */
const RENDERERS: Record<string, (h: any, item: ActionItem) => any> = { icon: actionIcon, text: actionText };
export function actionCell(h: any, items: ActionItem[], opts: ActionCellOpts = {}) {
const mode = opts.mode ?? "text";
const { inline, overflow } = (opts.collapsible ?? true)
? splitActions(items, opts.maxInline ?? 2)
: { inline: items, overflow: [] };
const render = RENDERERS[mode] || actionText;
const children = inline.map((it) => render(h, it));
if (overflow.length) children.push(actionMore(h, overflow));
return h("div", { class: "op-cell" }, children); // flex 横排容器,见 main.scss
}
/** 产出可直接塞进 columns 的操作列对象;列宽按「声明数」估算,when 过滤后只宽不窄 */
export function actionColumn(items: ActionItem[], opts: ActionCellOpts = {}) {
const mode = opts.mode ?? "text";
return {
title: "操作",
key: "action",
width: opts.width ?? (mode === "icon" ? items.length * 40 + 40 : 180),
align: opts.align ?? "center",
...(opts.fixed === undefined ? {} : { fixed: opts.fixed === false ? undefined : opts.fixed }),
render: (h: any, p: any) => actionCell(h, normalizeActions(items, p.row), opts),
};
}
+2 -2
View File
@@ -372,14 +372,14 @@ onMounted(async () => {
} }
.school-name { .school-name {
color: #2d8cf0; color: var(--brand);
font-weight: 600; font-weight: 600;
} }
.authz-panel { .authz-panel {
position: relative; position: relative;
flex: 1; flex: 1;
min-height: 320px; min-height: 0;
padding: 12px 16px; padding: 12px 16px;
background: #fff; background: #fff;
border-radius: 4px; border-radius: 4px;
+61 -11
View File
@@ -90,6 +90,10 @@ type AppItem = {
id?: number; id?: number;
name?: string; name?: string;
sortNo?: number; sortNo?: number;
/** 应用级勾选态:仅当 modules 为空时有意义(对应后端 module_id=0 的整应用授权记录) */
checked?: boolean;
/** 应用级子组继承:仅当 modules 为空时有意义 */
inheritFlag?: boolean;
modules?: AppModuleVo[]; modules?: AppModuleVo[];
}; };
@@ -221,19 +225,22 @@ function countTotalModules(nodes: AppModuleVo[]): number {
function isAppChecked(app: AppItem): boolean { function isAppChecked(app: AppItem): boolean {
const modules = app.modules || []; const modules = app.modules || [];
if (modules.length === 0) return false; // 无子模块应用(如「一周安排」):勾选态来自后端返回的整应用授权记录
return modules.every((m) => isModuleAllChecked(m)); if (modules.length === 0) return !!app.checked;
// 父级展示口径:只要有一个子节点被勾选就算选中(不采用 zTree 半选,见样式里 true_part 的覆写)
return modules.some((m) => isModuleAnyChecked(m));
} }
function isAppInherit(app: AppItem): boolean { function isAppInherit(app: AppItem): boolean {
const modules = app.modules || []; const modules = app.modules || [];
if (modules.length === 0) return false; if (modules.length === 0) return !!app.inheritFlag;
return modules.every((m) => isModuleAllInherit(m)); return modules.every((m) => isModuleAllInherit(m));
} }
function isModuleAllChecked(node: AppModuleVo): boolean { /** 自身或任一子孙被勾选 */
if (!node.checked) return false; function isModuleAnyChecked(node: AppModuleVo): boolean {
return (node.children || []).every((c) => isModuleAllChecked(c)); if (node.checked) return true;
return (node.children || []).some((c) => isModuleAnyChecked(c));
} }
function isModuleAllInherit(node: AppModuleVo): boolean { function isModuleAllInherit(node: AppModuleVo): boolean {
@@ -248,7 +255,8 @@ function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
id: `mod_${m.id}`, id: `mod_${m.id}`,
name: String(m.name || ""), name: String(m.name || ""),
open: true, open: true,
checked: !!m.checked, // 父模块节点沿用「有子节点选中即选中」的展示口径,与顶层应用节点一致
checked: isModuleAnyChecked(m),
inheritFlag: !!m.inheritFlag, inheritFlag: !!m.inheritFlag,
nodeType: "module", nodeType: "module",
rawId: Number(m.id), rawId: Number(m.id),
@@ -423,6 +431,10 @@ function findLocalApp(appId: number): AppItem | null {
function setLocalAppInherit(appId: number, inherit: boolean) { function setLocalAppInherit(appId: number, inherit: boolean) {
const app = findLocalApp(appId); const app = findLocalApp(appId);
if (!app) return; if (!app) return;
if ((app.modules || []).length === 0) {
app.inheritFlag = inherit;
return;
}
walkModules(app.modules || [], (n) => { walkModules(app.modules || [], (n) => {
if (n.checked) n.inheritFlag = inherit; if (n.checked) n.inheritFlag = inherit;
}); });
@@ -453,6 +465,33 @@ async function onTreeCheck(node: any) {
return; return;
} }
// 无子模块应用(如「一周安排」):授权粒度即整个应用,落 moduleId=0 的整应用记录。
// 这类应用没有可 diff 的模块明细,必须单独走应用级提交,否则勾选只是个视觉状态、刷新即失效。
const appModules = app.modules || [];
if (appModules.length === 0) {
const checked = !!node.checked;
try {
if (checked !== !!app.checked) {
app.checked = checked;
await Apis.GroupAuthz.setGroupModule({
data: [{
groupId,
appId: node.appId,
moduleId: 0,
mode: checked ? addMode.value : deleteMode.value,
isAll: 0,
}],
cacheFor: null,
});
}
syncInheritDoms(treeObj);
} catch (e: any) {
console.warn(e?.message || "操作失败");
await reloadCurrent();
}
return;
}
// 级联前快照:用于计算本次实际变化的模块明细 // 级联前快照:用于计算本次实际变化的模块明细
const before = new Map<number, boolean>(); const before = new Map<number, boolean>();
walkModules(app.modules, (m) => before.set(Number(m.id), !!m.checked)); walkModules(app.modules, (m) => before.set(Number(m.id), !!m.checked));
@@ -603,14 +642,14 @@ onMounted(async () => {
} }
.group-name { .group-name {
color: #2d8cf0; color: var(--brand);
font-weight: 600; font-weight: 600;
} }
.authz-panel { .authz-panel {
position: relative; position: relative;
flex: 1; flex: 1;
min-height: 320px; min-height: 0;
padding: 12px 16px; padding: 12px 16px;
background: #fff; background: #fff;
border-radius: 4px; border-radius: 4px;
@@ -729,13 +768,22 @@ onMounted(async () => {
} }
.open-link { .open-link {
color: #2d8cf0; color: var(--brand);
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
} }
/* 父级勾选样式(2026-10-20):
zTree 的「半选」在两个状态位各有一套图标——true_part(父已勾但子未全勾)/
false_part(父未勾但子有勾)。这里把 true_part 的图标换成 true_full 的蓝底勾,
让"只要有一个子节点被选中"的父节点统一呈现为选中态,避免折叠时看着像没授权。 */
:deep(.ztree li span.button.chk.checkbox_true_part),
:deep(.ztree li span.button.chk.checkbox_true_part_focus) {
background-position: -26px -5px;
}
/* zTree 节点行尾的"子组继承"复选框: /* zTree 节点行尾的"子组继承"复选框:
float 会跟随节点缩进产生阶梯,改为绝对定位锚定卡片体右边界,所有层级右对齐成一列 */ float 会跟随节点缩进产生阶梯,改为绝对定位锚定卡片体右边界,所有层级右对齐成一列 */
:deep(.inherit-wrap) { :deep(.inherit-wrap) {
@@ -755,7 +803,9 @@ onMounted(async () => {
} }
:deep(.ztree li a) { :deep(.ztree li a) {
max-width: calc(100% - 90px); /* 子组继承复选框仅悬停时由 addHoverDom 注入(绝对定位 right:14,占位约 30px),
常态无需预留 90px——收窄预留,消除「明明有空间却截断成 ...」 */
max-width: calc(100% - 36px);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
+37 -56
View File
@@ -31,7 +31,7 @@
style="width:220px" @on-search="onSearch" @on-clear="onSearch" /> style="width:220px" @on-search="onSearch" @on-clear="onSearch" />
</div> </div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columnsComp" :tableHeight="tableHeight" /> <TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columnsComp" />
</div> </div>
<!-- 容器组件:新增 / 编辑 --> <!-- 容器组件:新增 / 编辑 -->
@@ -51,7 +51,6 @@
import { ref, computed } from "vue"; import { ref, computed } from "vue";
defineOptions({ name: "AppList" }); defineOptions({ name: "AppList" });
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import MyConfirm from "@/components/MyConfirm"; import MyConfirm from "@/components/MyConfirm";
import AddAppContainer from "./AddAppContainer.vue"; import AddAppContainer from "./AddAppContainer.vue";
import EditAppContainer from "./EditAppContainer.vue"; import EditAppContainer from "./EditAppContainer.vue";
@@ -60,9 +59,9 @@ import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import { OperatorEnum } from '@/types'; import { OperatorEnum } from '@/types';
import AppSort from './AppSort.vue'; import AppSort from './AppSort.vue';
import { useAppSchoolScope } from "@/utils/hook/useAppSchoolScope"; import { useAppSchoolScope } from "@/utils/hook/useAppSchoolScope";
import { actionColumn } from "@/utils/tableActions";
const router = useRouter(); const router = useRouter();
const { $utils } = useCurrentInstance();
// 学校管理员上下文:多校显示切换下拉,覆盖仅对所选学校生效(默认并集,写操作须先选定学校) // 学校管理员上下文:多校显示切换下拉,覆盖仅对所选学校生效(默认并集,写操作须先选定学校)
const { isSchoolMode, multiSchool, schoolList, currentSchoolId, requireSchool } = useAppSchoolScope(); const { isSchoolMode, multiSchool, schoolList, currentSchoolId, requireSchool } = useAppSchoolScope();
@@ -146,10 +145,6 @@ function onSortSaved() {
tableRef.value?.reloadData(OperatorEnum.sel); tableRef.value?.reloadData(OperatorEnum.sel);
} }
const tableHeight = ref(
$utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true })
);
/* ---------------- 表格固定列(不含“应用类型”列) ---------------- */ /* ---------------- 表格固定列(不含“应用类型”列) ---------------- */
const columns = [ const columns = [
{ type: "index", title: "序号", width: 70 }, { type: "index", title: "序号", width: 70 },
@@ -174,25 +169,41 @@ const columns = [
width: 80, width: 80,
render: (h: any, { row }: any) => h("span", row.appNature === "BASE" ? "基石" : "业务"), render: (h: any, { row }: any) => h("span", row.appNature === "BASE" ? "基石" : "业务"),
}, },
{ actionColumn(
title: "操作", [
key: "action",
align: 'center',
width: 280,
render: (h: any, { row }: any) => {
// SCHOOL 模式:仅编辑(名称/描述/图标,写学校覆盖表);模块管理/推送设置/删除全部隐藏。 // SCHOOL 模式:仅编辑(名称/描述/图标,写学校覆盖表);模块管理/推送设置/删除全部隐藏。
// 基石应用(app_nature=BASE)只做身份注册:模块/推送置灰;删除允许——基石也可下线, // 基石应用(app_nature=BASE)只做身份注册:模块/推送置灰;删除允许——基石也可下线,
// 但走「输名确认」删除模(危险确认,与 projman 应用删除同款)。 // 但走「输名确认」删除模(危险确认,与 projman 应用删除同款)。
const base = row.appNature === "BASE"; { key: "edit", label: "编辑", icon: "icon-edit", on: (r: any) => handleEdit(r) },
const actions = [$utils.operatingTextHandle(h, '编辑', '', () => handleEdit(row))]; {
if (!isSchoolMode.value) { key: "module",
actions.push($utils.operatingTextHandle(h, '模块管理', '', () => goModuleManage(row), base)); label: "模块管理",
actions.push($utils.operatingTextHandle(h, '推送设置', '', () => openPushSetting(row), base)); icon: "icon-apps",
actions.push($utils.operatingTextHandle(h, '删除', 'text-error', () => handleDelete(row))); on: (r: any) => goModuleManage(r),
} when: () => !isSchoolMode.value,
return actions; disabledWhen: (r: any) => r.appNature === "BASE",
} },
}, {
key: "push",
label: "推送设置",
icon: "icon-fasong",
on: (r: any) => openPushSetting(r),
when: () => !isSchoolMode.value,
disabledWhen: (r: any) => r.appNature === "BASE",
},
{
key: "del",
label: "删除",
icon: "icon-ashbin",
on: (r: any) => handleDelete(r),
when: () => !isSchoolMode.value,
danger: true,
},
],
// UserBase 恒文字链(工程风格显式声明,不随动作数漂移);
// icon 字段仅留作未来切 mode:"icon" 的备选。4 动作 → 2 直出 + 推送设置/删除进「更多」
{ mode: "text", width: 180 },
),
]; ];
// 条件列:仅在「未筛选应用类型」时插入到“应用名称”之后 // 条件列:仅在「未筛选应用类型」时插入到“应用名称”之后
@@ -288,7 +299,6 @@ const goModuleManage = (row: any) => {
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
padding: 0; padding: 0;
width: 180px;
} }
.school-select { .school-select {
@@ -310,7 +320,7 @@ const goModuleManage = (row: any) => {
border-top: 1px solid #eeeeee; border-top: 1px solid #eeeeee;
.ivu-btn-text { .ivu-btn-text {
color: #577afb; color: var(--brand);
} }
} }
@@ -331,37 +341,8 @@ const goModuleManage = (row: any) => {
} }
li.active { li.active {
color: #577afb; color: var(--brand);
border-color: #577afb; border-color: var(--brand);
} }
} }
.action-cell {
display: flex;
align-items: center;
justify-content: center;
}
.action-link {
cursor: pointer;
color: #577afb;
font-size: 13px;
&:hover {
color: #3d5afe;
}
&.danger {
color: #ed4014;
&:hover {
color: #c51d04;
}
}
}
.action-divider {
margin: 0 8px;
color: #dcdee2;
}
</style> </style>
+26 -29
View File
@@ -27,14 +27,14 @@
<Input placeholder="请输入模块名称" v-model="form.name" /> <Input placeholder="请输入模块名称" v-model="form.name" />
</FormItem> </FormItem>
<FormItem label="模块路径:" prop="path"> <FormItem label="模块路径:" prop="path">
<Input placeholder="请输入模块路径" v-model="form.path" /> <Input :placeholder="openingIsChild ? '选填;作按钮权限时可留空' : '请输入模块路径'" v-model="form.path" />
</FormItem> </FormItem>
<FormItem label="模块图标:" prop="icon"> <FormItem label="模块图标:" prop="icon">
<Input placeholder="选填,YschIcons 图标名(如 notice-list);留空则不下发图标" <Input placeholder="选填,YschIcons 图标名(如 notice-list);留空则不下发图标"
v-model="form.icon" clearable /> v-model="form.icon" clearable />
</FormItem> </FormItem>
<FormItem label="自定义ID:" prop="customId"> <FormItem label="自定义ID:" prop="customId">
<InputNumber :style="{ width: '100%' }" placeholder="请输入自定义ID" v-model="form.customId" /> <InputNumber :style="{ width: '100%' }" :placeholder="openingIsChild ? '选填;作按钮权限时可留空' : '请输入自定义ID'" v-model="form.customId" />
</FormItem> </FormItem>
<FormItem label="排序号:" prop="sortNo"> <FormItem label="排序号:" prop="sortNo">
<InputNumber :style="{ width: '100%' }" placeholder="请输入排序号" v-model="form.sortNo" /> <InputNumber :style="{ width: '100%' }" placeholder="请输入排序号" v-model="form.sortNo" />
@@ -54,9 +54,7 @@ import { useRoute, useRouter } from "vue-router";
import { debounce } from "lodash"; import { debounce } from "lodash";
import SvgIcon from "@/components/SvgIcon.vue"; import SvgIcon from "@/components/SvgIcon.vue";
import MyConfirm from "@/components/MyConfirm"; import MyConfirm from "@/components/MyConfirm";
import useCurrentInstance from "@/utils/hook/useCurrentInstance"; import { actionColumn } from "@/utils/tableActions";
const { $utils } = useCurrentInstance();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -116,22 +114,18 @@ const columns = [
{ title: "ID", key: "customId", width: 120, align: "center", render: (h: any, { row }: any) => h("span", row.customId || "-") }, { title: "ID", key: "customId", width: 120, align: "center", render: (h: any, { row }: any) => h("span", row.customId || "-") },
{ title: "路径", key: "path", minWidth: 200, render: (h: any, { row }: any) => h("span", row.path || "-") }, { title: "路径", key: "path", minWidth: 200, render: (h: any, { row }: any) => h("span", row.path || "-") },
{ title: "排序号", key: "sortNo", width: 100, align: "center", render: (h: any, { row }: any) => h("span", row.sortNo ?? "-") }, { title: "排序号", key: "sortNo", width: 100, align: "center", render: (h: any, { row }: any) => h("span", row.sortNo ?? "-") },
{ // UserBase 恒文字链;3 动作 ≤ maxInline+1 → 全直出(与改前形态一致),列宽 220 → 180
title: "操作", actionColumn([
key: "action", { key: "edit", label: "编辑", icon: "icon-edit", on: (r: any) => handleEdit(r) },
width: 220, {
align: "center", key: "add",
render: (h: any, { row }: any) => { label: "添加子模块",
const actions: any[] = [ icon: "icon-tianjia",
$utils.operatingTextHandle(h, '编辑', '', () => handleEdit(row)), on: (r: any) => handleAddChild(r),
$utils.operatingTextHandle(h, '删除', 'text-error', () => handleDelete(row)), when: (r: any) => r.level < 5,
];
if (row.level < 5) {
actions.splice(1, 0, $utils.operatingTextHandle(h, '添加子模块', '', () => handleAddChild(row)));
}
return h("div", { style: { display: "flex", justifyContent: "center", gap: "4px" } }, actions);
}, },
}, { key: "del", label: "删除", icon: "icon-ashbin", on: (r: any) => handleDelete(r), danger: true },
], { mode: "text", width: 180 }),
]; ];
/* ---------------- 弹窗状态 ---------------- */ /* ---------------- 弹窗状态 ---------------- */
@@ -159,13 +153,16 @@ const form = ref<{
sortNo: null, sortNo: null,
}); });
const ruleValidate = ref({
name: [{ required: true, message: "请输入模块名称", trigger: "blur" }],
path: [{ required: true, message: "请输入模块路径", trigger: "blur" }],
customId: [{ required: true, type: "number", message: "请输入自定义ID", trigger: "change" }],
});
const openingIsChild = ref(false); const openingIsChild = ref(false);
/* 子模块双定位:树节点(带路径/编码)或纯按钮权限(两者皆可空)→ 仅根模块必填 */
const ruleValidate = computed(() => ({
name: [{ required: true, message: "请输入模块名称", trigger: "blur" }],
...(openingIsChild.value ? {} : {
path: [{ required: true, message: "请输入模块路径", trigger: "blur" }],
customId: [{ required: true, type: "number", message: "请输入自定义ID", trigger: "change" }],
}),
}));
const modalTitle = computed(() => { const modalTitle = computed(() => {
if (currentId.value != null) return '编辑模块'; if (currentId.value != null) return '编辑模块';
return openingIsChild.value ? '新增子模块' : '新增模块'; return openingIsChild.value ? '新增子模块' : '新增模块';
@@ -264,7 +261,7 @@ async function onRawSave() {
data: { data: {
appId: appId.value, appId: appId.value,
name: form.value.name, name: form.value.name,
path: form.value.path, path: form.value.path?.trim() || undefined,
icon: form.value.icon?.trim() || undefined, icon: form.value.icon?.trim() || undefined,
customId: form.value.customId == null ? '' : String(form.value.customId), customId: form.value.customId == null ? '' : String(form.value.customId),
parentId: form.value.parentId ?? undefined, parentId: form.value.parentId ?? undefined,
@@ -278,8 +275,8 @@ async function onRawSave() {
id: currentId.value, id: currentId.value,
appId: appId.value, appId: appId.value,
name: form.value.name, name: form.value.name,
path: form.value.path, path: form.value.path?.trim() || undefined,
// 后端 edit 逐字段整体赋值,字段缺省即置空 → 清空图标能生效 // 后端 edit 逐字段整体赋值,字段缺省即置空 → 清空图标/路径能生效
icon: form.value.icon?.trim() || undefined, icon: form.value.icon?.trim() || undefined,
customId: form.value.customId == null ? '' : String(form.value.customId), customId: form.value.customId == null ? '' : String(form.value.customId),
parentId: form.value.parentId ?? undefined, parentId: form.value.parentId ?? undefined,
+200
View File
@@ -0,0 +1,200 @@
<!-- 自定义部门卡片(已被 CustomDeptPanel.vue 的表格取代,2026-10-23)。保留文件仅作参考,无任何引用,可安全删除。 -->
<template>
<article class="cdept-card" :class="{ 'is-off': !dept.enabled }">
<div class="row-1">
<span class="status-dot" :class="{ 'is-off': !dept.enabled }"></span>
<h3 class="card-name" :title="dept.name">{{ dept.name }}</h3>
<span class="sw" :class="{ 'is-off': !dept.enabled }" :title="dept.enabled ? '点击停用' : '点击启用'"
@click="emit('toggle', dept)"></span>
</div>
<div class="row-2">
<span class="sub">{{ memberText }}</span>
<span class="ops">
<i class="op" :class="{ 'is-disabled': !canMoveUp }" title="上移" @click="emit('moveUp')">↑</i>
<i class="op" :class="{ 'is-disabled': !canMoveDown }" title="下移" @click="emit('moveDown')">↓</i>
<a class="link" @click="emit('members', dept)">成员</a>
<a class="link" @click="emit('edit', dept)">编辑</a>
<a class="link danger" @click="emit('remove', dept)">删除</a>
</span>
</div>
</article>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import type { CustomDepartmentVo } from "@/api/globals";
/**
* CUSTOM 部门卡片:与 SYSTEM 的 DeptCard 同款外观,但字段来自 ub_custom_department。
* 停用在管理端照常可见(灰态),软删的部门由后端列表过滤、不会走到这里。
*/
const props = defineProps<{
dept: CustomDepartmentVo;
canMoveUp?: boolean;
canMoveDown?: boolean;
}>();
const emit = defineEmits<{
(e: "toggle", dept: CustomDepartmentVo): void;
(e: "edit", dept: CustomDepartmentVo): void;
(e: "remove", dept: CustomDepartmentVo): void;
(e: "members", dept: CustomDepartmentVo): void;
(e: "moveUp"): void;
(e: "moveDown"): void;
}>();
const memberText = computed(() => {
const count = Number(props.dept?.memberCount ?? 0);
if (!props.dept?.enabled) return count ? `已停用 · ${count} 人` : "已停用 · 无成员";
return count ? `${count} 人` : "无成员(可先建部门再配人)";
});
</script>
<style scoped lang="scss">
.cdept-card {
display: flex;
flex-direction: column;
gap: 9px;
padding: 13px 14px;
background: var(--panel, #fff);
border: 1px solid var(--line, #e9edf5);
border-radius: var(--r, 10px);
box-shadow: 0 1px 2px rgba(30, 38, 53, .03);
transition: border-color .15s, box-shadow .15s;
}
.cdept-card:hover {
border-color: #dbe2fb;
box-shadow: 0 6px 18px rgba(30, 38, 53, .07);
}
.cdept-card.is-off {
background: #fbfcfd;
border-style: dashed;
}
.row-1 {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
flex: 0 0 6px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--green, #20a162);
}
.status-dot.is-off {
background: #cbd2df;
}
.card-name {
flex: 1;
min-width: 0;
margin: 0;
font-size: 15px;
font-weight: 600;
line-height: 1.4;
color: var(--text, #1e2635);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cdept-card.is-off .card-name {
color: var(--text-3, #98a1b2);
}
.row-2 {
display: flex;
align-items: center;
gap: 8px;
min-height: 26px;
margin-top: auto;
padding-top: 9px;
border-top: 1px dashed var(--line-2, #f0f3f9);
font-size: 12px;
color: var(--text-3, #98a1b2);
}
.row-2 .sub {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ops {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 8px;
}
.link {
color: var(--brand, #577afb);
cursor: pointer;
}
.link.danger {
color: #e54545;
}
.op {
font-style: normal;
font-size: 12px;
line-height: 18px;
width: 18px;
text-align: center;
border-radius: 4px;
color: var(--text-2, #5a6478);
cursor: pointer;
}
.op:hover {
background: #e8ecfa;
color: var(--brand, #577afb);
}
.op.is-disabled {
color: #d3d9e6;
cursor: not-allowed;
}
/* 启停开关(与 SYSTEM 卡片同一套视觉) */
.sw {
position: relative;
flex: 0 0 auto;
width: 36px;
height: 20px;
border-radius: 10px;
background: var(--brand, #577afb);
cursor: pointer;
transition: background .15s;
}
.sw::after {
content: '';
position: absolute;
top: 2px;
left: 18px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(30, 38, 53, .22);
transition: left .15s;
}
.sw.is-off {
background: #d3d9e6;
}
.sw.is-off::after {
left: 2px;
}
</style>
File diff suppressed because it is too large Load Diff
+485
View File
@@ -0,0 +1,485 @@
<template>
<Modal v-model="innerVisible" :title="title" width="760" :mask-closable="false"
@on-visible-change="onVisibleChange">
<div class="member-body">
<!-- 左:全部人员(本校教职工,服务端已排除学生组与在册成员) -->
<section class="pane">
<div class="pane-head">
<Checkbox :model-value="leftAllChecked" :indeterminate="leftIndeterminate"
@on-change="(v: any) => toggleAll(leftRows, leftChecked, !!v)" />
<span class="pane-title">全部人员</span>
<span class="pane-sub">{{ leftCheckedCount }}/{{ leftRows.length }}</span>
</div>
<div class="pane-filter">
<Input v-model="kwLeft" size="small" placeholder="请输入搜索内容" clearable search
@on-search="loadCandidates" @on-change="onKwLeftChange" />
</div>
<div v-if="loadingCandidates" class="pane-tip">加载中…</div>
<div v-else-if="!leftRows.length" class="pane-tip">
{{ kwLeft ? '没有匹配的人员' : '没有可添加的教职工' }}
</div>
<ul v-else class="pane-list">
<li v-for="u in leftRows" :key="u.userId" class="row" @dblclick="moveRightOne(u)">
<Checkbox :model-value="isChecked(leftChecked, u)" @on-change="(v: any) => toggleOne(leftChecked, u, !!v)">
<span class="row-name">{{ u.name || u.account }}</span>
<span class="row-sub">{{ u.account }}</span>
</Checkbox>
</li>
</ul>
<div v-if="candidates.length >= CANDIDATE_PAGE_HINT" class="pane-foot">
仅显示前 {{ CANDIDATE_PAGE_HINT }} 条,可输入关键字缩小范围。
</div>
</section>
<!-- 中:加入 / 移出 -->
<div class="mid">
<Button size="small" class="mid-btn" :disabled="!rightCheckedCount" title="移出成员"
@click="moveLeft()">&lt;</Button>
<Button size="small" type="primary" class="mid-btn" :disabled="!leftCheckedCount"
title="加入成员" @click="moveRight()">&gt;</Button>
</div>
<!-- 右:已有成员 -->
<section class="pane">
<div class="pane-head">
<Checkbox :model-value="rightAllChecked" :indeterminate="rightIndeterminate"
@on-change="(v: any) => toggleAll(rightRows, rightChecked, !!v)" />
<span class="pane-title">已有成员</span>
<span class="pane-sub">{{ rightRows.length }} 人</span>
</div>
<div class="pane-filter">
<Input v-model="kwRight" size="small" placeholder="请输入搜索内容" clearable />
</div>
<div v-if="loadingMembers" class="pane-tip">加载中…</div>
<div v-else-if="!rightRows.length" class="pane-tip">
{{ kwRight ? '没有匹配的成员' : '该部门还没有成员' }}
</div>
<ul v-else class="pane-list">
<li v-for="u in rightRows" :key="u.userId" class="row" @dblclick="moveLeftOne(u)">
<Checkbox :model-value="isChecked(rightChecked, u)" @on-change="(v: any) => toggleOne(rightChecked, u, !!v)">
<span class="row-name">{{ u.name || u.account }}</span>
<span class="row-sub">{{ u.account }}</span>
</Checkbox>
</li>
</ul>
</section>
</div>
<template #footer>
<div class="modal-footer">
<span v-if="pendingText" class="pending-tip">{{ pendingText }}</span>
<Button @click="innerVisible = false">取消</Button>
<Button type="primary" :loading="saving" :disabled="!hasPending" @click="save">保存</Button>
</div>
</template>
</Modal>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import Apis from "@/api";
import type { CustomDepartmentMemberVo, CustomDepartmentVo } from "@/api/globals";
/**
* 自定义部门成员管理弹窗(穿梭框式)。
* 左「全部人员」=本校教职工候选(服务端按「排除学生组 + 未在本部门」给),右「已有成员」=部门在册成员。
* 中间箭头只改本地待提交状态,点「保存」才一次性提交:先 Add 再 Remove,取消则整体丢弃。
* 候选走 {@code /api/customdept/CandidateUsers/{id}},不能用组维度的 GroupUsers(会带进学生)。
*/
const props = defineProps<{ visible: boolean; dept?: CustomDepartmentVo | null }>();
const emit = defineEmits<{ "update:visible": [boolean]; success: [] }>();
/** 后端候选上限 200,达到就提示用关键字筛选 */
const CANDIDATE_PAGE_HINT = 200;
const innerVisible = computed({
get: () => props.visible,
set: (v) => emit("update:visible", v),
});
const title = computed(() => `部门成员 · ${props.dept?.name || ""}`);
const deptId = computed(() => props.dept?.id ?? null);
/** 服务端数据 */
const members = ref<CustomDepartmentMemberVo[]>([]);
const candidates = ref<CustomDepartmentMemberVo[]>([]);
const loadingMembers = ref(false);
const loadingCandidates = ref(false);
const saving = ref(false);
/** 待提交:新增(左→右)与移出(右→左)。点保存才落库,取消整体丢弃 */
const pendingAdd = ref<CustomDepartmentMemberVo[]>([]);
const pendingRemove = ref<Set<number>>(new Set());
const kwLeft = ref("");
const kwRight = ref("");
const leftChecked = ref<Set<number>>(new Set());
const rightChecked = ref<Set<number>>(new Set());
function matchKw(u: CustomDepartmentMemberVo, k: string) {
return (
String(u.name || "").toLowerCase().includes(k) ||
String(u.account || "").toLowerCase().includes(k)
);
}
/**
* 左列表:候选池去掉待加入的,再补上「待移出的成员」——
* 服务端候选池天然不含在册成员,若不补回来,移出后想反悔就找不到了。
*/
const leftRows = computed<CustomDepartmentMemberVo[]>(() => {
const k = kwLeft.value.trim().toLowerCase();
const hit = (u: CustomDepartmentMemberVo) => !k || matchKw(u, k);
const back = members.value.filter((m) => pendingRemove.value.has(m.userId as number) && hit(m));
const pool = candidates.value.filter(
(c) => !pendingRemove.value.has(c.userId as number) && !isPendingAdd(c.userId as number) && hit(c)
);
return [...back, ...pool];
});
/** 右列表:在册成员去掉待移出的,加上待加入的 */
const rightRows = computed<CustomDepartmentMemberVo[]>(() => {
const k = kwRight.value.trim().toLowerCase();
const hit = (u: CustomDepartmentMemberVo) => !k || matchKw(u, k);
const held = members.value.filter((m) => !pendingRemove.value.has(m.userId as number));
return [...held, ...pendingAdd.value].filter(hit);
});
function isPendingAdd(userId: number) {
return pendingAdd.value.some((u) => u.userId === userId);
}
const hasPending = computed(() => pendingAdd.value.length > 0 || pendingRemove.value.size > 0);
const pendingText = computed(() => {
const parts: string[] = [];
if (pendingAdd.value.length) parts.push(`新增 ${pendingAdd.value.length} 人`);
if (pendingRemove.value.size) parts.push(`移出 ${pendingRemove.value.size} 人`);
return parts.join(" · ");
});
/* ================= 勾选 ================= */
function isChecked(box: Set<number>, u: CustomDepartmentMemberVo) {
return box.has(u.userId as number);
}
function toggleOne(box: Set<number>, u: CustomDepartmentMemberVo, checked: boolean) {
const userId = u.userId as number;
if (checked) box.add(userId);
else box.delete(userId);
// Set 自身不是响应式依赖,换一个实例触发重算
if (box === leftChecked.value) leftChecked.value = new Set(box);
else rightChecked.value = new Set(box);
}
function moveRightOne(u: CustomDepartmentMemberVo) {
if (u.userId) moveRight([u.userId]);
}
function moveLeftOne(u: CustomDepartmentMemberVo) {
if (u.userId) moveLeft([u.userId]);
}
function toggleAll(rows: CustomDepartmentMemberVo[], box: Set<number>, checked: boolean) {
const ids = rows.map((u) => u.userId as number);
const next = new Set(box);
ids.forEach((id) => (checked ? next.add(id) : next.delete(id)));
if (box === leftChecked.value) leftChecked.value = next;
else rightChecked.value = next;
}
/* 全选 / 半选一律按「当前可见行」判断:关键字筛选后仍被勾着的行不算数 */
const leftCheckedCount = computed(
() => leftRows.value.filter((u) => leftChecked.value.has(u.userId as number)).length
);
const rightCheckedCount = computed(
() => rightRows.value.filter((u) => rightChecked.value.has(u.userId as number)).length
);
const leftAllChecked = computed(() => leftRows.value.length > 0 && leftCheckedCount.value >= leftRows.value.length);
const leftIndeterminate = computed(() => leftCheckedCount.value > 0 && !leftAllChecked.value);
const rightAllChecked = computed(() => rightRows.value.length > 0 && rightCheckedCount.value >= rightRows.value.length);
const rightIndeterminate = computed(() => rightCheckedCount.value > 0 && !rightAllChecked.value);
/* ================= 左右移动 ================= */
/** 加入:左侧勾选(或双击某行)→ 挂到待新增 */
function moveRight(userIds?: number[]) {
const ids = userIds ?? leftRows.value.filter((u) => leftChecked.value.has(u.userId as number)).map((u) => u.userId as number);
if (!ids.length) return;
const picked = leftRows.value.filter((u) => ids.includes(u.userId as number));
pendingAdd.value = [...pendingAdd.value, ...picked];
// 移出后再加入 = 撤销移出,不用重复 Add
const nextRemove = new Set(pendingRemove.value);
picked.forEach((u) => nextRemove.delete(u.userId as number));
pendingRemove.value = nextRemove;
leftChecked.value = new Set();
}
/** 移出:右侧勾选(或双击某行)→ 撤掉待新增,或记账到待移出 */
function moveLeft(userIds?: number[]) {
const ids = userIds ?? rightRows.value.filter((u) => rightChecked.value.has(u.userId as number)).map((u) => u.userId as number);
if (!ids.length) return;
const stillPending = new Set<number>();
pendingAdd.value = pendingAdd.value.filter((u) => {
if (ids.includes(u.userId as number)) {
stillPending.add(u.userId as number);
return false;
}
return true;
});
const nextRemove = new Set(pendingRemove.value);
ids.forEach((id) => {
if (!stillPending.has(id)) nextRemove.add(id);
});
pendingRemove.value = nextRemove;
rightChecked.value = new Set();
}
/* ================= 数据加载 ================= */
async function loadMembers() {
if (!deptId.value) return;
loadingMembers.value = true;
try {
members.value = ((await Apis.CustomDept.customDeptMembers({
pathParams: { id: deptId.value },
cacheFor: null,
})) as CustomDepartmentMemberVo[]) || [];
} catch (e: any) {
console.warn(e?.message || "加载部门成员失败");
members.value = [];
} finally {
loadingMembers.value = false;
}
}
async function loadCandidates() {
if (!deptId.value) return;
loadingCandidates.value = true;
try {
candidates.value = ((await Apis.CustomDept.customDeptCandidateUsers({
pathParams: { id: deptId.value },
params: { keyword: kwLeft.value.trim() || undefined },
cacheFor: null,
})) as CustomDepartmentMemberVo[]) || [];
} catch (e: any) {
console.warn(e?.message || "加载候选教职工失败");
candidates.value = [];
} finally {
loadingCandidates.value = false;
}
}
function onKwLeftChange() {
// clearable 清空时也当一次搜索
if (!kwLeft.value) loadCandidates();
}
/* ================= 保存 ================= */
async function save() {
if (!deptId.value || !hasPending.value) return;
saving.value = true;
try {
if (pendingAdd.value.length) {
await Apis.CustomDept.customDeptAddMembers({
pathParams: { id: deptId.value },
data: {
schoolId: props.dept?.schoolId as number,
userIds: pendingAdd.value.map((u) => u.userId as number),
},
cacheFor: null,
});
}
if (pendingRemove.value.size) {
await Apis.CustomDept.customDeptRemoveMembers({
pathParams: { id: deptId.value },
data: { schoolId: props.dept?.schoolId as number, userIds: [...pendingRemove.value] },
cacheFor: null,
});
}
Message.success("已保存");
emit("success");
innerVisible.value = false;
} catch (e: any) {
// 失败提示由全局 alova 拦截器弹出,弹窗保持打开让用户重试
console.warn(e?.message || "保存成员失败");
} finally {
saving.value = false;
}
}
/* ================= 生命周期 ================= */
function reset() {
members.value = [];
candidates.value = [];
pendingAdd.value = [];
pendingRemove.value = new Set();
leftChecked.value = new Set();
rightChecked.value = new Set();
kwLeft.value = "";
kwRight.value = "";
}
function onVisibleChange(v: boolean) {
if (!v) return;
reset();
loadMembers();
loadCandidates();
}
// 外部直接改 visible 时同步一次
watch(() => props.visible, (v) => {
if (v) onVisibleChange(true);
});
</script>
<style lang="scss" scoped>
.member-body {
display: flex;
align-items: stretch;
gap: 8px;
height: 430px;
}
.pane {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
border: 1px solid var(--line, #e2e6ef);
border-radius: 4px;
overflow: hidden;
background: #fff;
}
.pane-head {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
height: 38px;
padding: 0 10px;
border-bottom: 1px solid #f1f4f9;
}
.pane-title {
font-size: 13px;
font-weight: 600;
color: var(--text, #1e2635);
}
.pane-sub {
margin-left: auto;
font-size: 12px;
color: var(--text-3, #98a1b2);
white-space: nowrap;
}
.pane-filter {
flex: 0 0 auto;
padding: 8px 10px 6px;
}
.pane-list {
flex: 1;
min-height: 0;
margin: 0;
padding: 0 6px 6px;
list-style: none;
overflow: auto;
}
.pane-tip {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 0 16px;
font-size: 12.5px;
line-height: 1.8;
color: var(--text-3, #98a1b2);
}
.pane-foot {
flex: 0 0 auto;
padding: 6px 10px 8px;
font-size: 11.5px;
color: var(--text-3, #98a1b2);
border-top: 1px solid #f6f8fb;
}
.row {
display: flex;
align-items: center;
height: 30px;
padding: 0 4px;
border-radius: 3px;
font-size: 13px;
cursor: pointer;
user-select: none;
&:hover {
background: #f5f7fb;
}
:deep(.ivu-checkbox-wrapper) {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
margin-right: 0;
}
:deep(.ivu-checkbox) {
flex: 0 0 auto;
margin-right: 6px;
}
}
.row-name {
font-weight: 500;
color: #272829;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-sub {
flex: 0 0 auto;
margin-left: 6px;
font-size: 12px;
color: #9aa1ad;
}
/* 中间箭头列 */
.mid {
flex: 0 0 36px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
}
.mid-btn {
width: 32px;
padding: 0;
font-weight: 600;
}
.modal-footer {
display: flex;
align-items: center;
gap: 8px;
}
.pending-tip {
margin-right: auto;
font-size: 12px;
color: var(--text-3, #98a1b2);
}
</style>
+197
View File
@@ -0,0 +1,197 @@
<!-- 系统部门卡片(已被 index.vue 的表格取代,2026-10-23)。保留文件仅作参考,无任何引用,可安全删除。 -->
<template>
<article class="dept-card" :class="{ 'is-off': disabled, 'has-kids': showKids }">
<div class="row-1">
<span class="status-dot" :class="{ 'is-off': disabled }"></span>
<h3 class="card-name" :title="dept.name">{{ dept.name }}</h3>
<span class="sw" :class="{ 'is-off': disabled }" title="启用 / 停用" @click.stop="emit('toggle', dept)"></span>
</div>
<div class="row-2">
<span class="sub">{{ subText }}</span>
</div>
<div v-if="showKids" class="kid-list">
<DeptCard v-for="kid in kids" :key="kid.id" :dept="kid" :level="level + 1"
:include-children="includeChildren" @toggle="onKidToggle" />
</div>
</article>
</template>
<script lang="ts" setup>
import { computed } from "vue";
/** 部门口径:教师组下挂的都是部门,只有学生组(type=8)不算部门 */
const STUDENT_GROUP_TYPE = 8;
const props = withDefaults(defineProps<{
dept: any;
/** 层级深度:0 = 一级部门,>0 = 下级部门 */
level?: number;
/** 是否包含下级部门(对应学校上的「包含子组」开关) */
includeChildren?: boolean;
}>(), {
level: 0,
includeChildren: true,
});
const emit = defineEmits<{ (e: "toggle", dept: any): void }>();
const onKidToggle = (dept: any) => emit("toggle", dept);
const kids = computed<any[]>(() => props.dept?.children || []);
const showKids = computed(() => props.includeChildren !== false && kids.value.length > 0);
const disabled = computed(() => props.dept?.status === 1);
const subText = computed(() => {
if (props.dept?.type === STUDENT_GROUP_TYPE) return "学生组 · 非部门";
if (props.level > 0) return "下级部门";
return kids.value.length ? `${kids.value.length} 个下级部门` : "无下级部门";
});
</script>
<style scoped lang="scss">
.dept-card {
display: flex;
flex-direction: column;
gap: 9px;
padding: 13px 14px;
background: var(--panel, #fff);
border: 1px solid var(--line, #e9edf5);
border-radius: var(--r, 10px);
box-shadow: 0 1px 2px rgba(30, 38, 53, .03);
transition: border-color .15s, box-shadow .15s;
}
.dept-card:hover {
border-color: #dbe2fb;
box-shadow: 0 6px 18px rgba(30, 38, 53, .07);
}
/* 有下级部门:跨整行成组,避免层级被压平 */
.dept-card.has-kids {
grid-column: 1 / -1;
}
.dept-card.is-off {
background: #fbfcfd;
border-style: dashed;
}
.row-1 {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
flex: 0 0 6px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--green, #20a162);
}
.status-dot.is-off {
background: #cbd2df;
}
.card-name {
flex: 1;
min-width: 0;
margin: 0;
font-size: 15px;
font-weight: 600;
line-height: 1.4;
color: var(--text, #1e2635);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dept-card.is-off .card-name {
color: var(--text-3, #98a1b2);
}
.row-2 {
display: flex;
align-items: center;
gap: 8px;
min-height: 26px;
margin-top: auto;
padding-top: 9px;
border-top: 1px dashed var(--line-2, #f0f3f9);
font-size: 12px;
color: var(--text-3, #98a1b2);
}
.row-2 .sub {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 启停开关 */
.sw {
position: relative;
flex: 0 0 auto;
width: 36px;
height: 20px;
border-radius: 10px;
background: var(--brand, #577afb);
cursor: pointer;
transition: background .15s;
}
.sw::after {
content: '';
position: absolute;
top: 2px;
left: 18px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(30, 38, 53, .22);
transition: left .15s;
}
.sw.is-off {
background: #d3d9e6;
}
.sw.is-off::after {
left: 2px;
}
/* 下级部门区 */
.kid-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 11px;
padding-top: 11px;
border-top: 1px dashed var(--line-2, #f0f3f9);
}
.kid-list :deep(.dept-card) {
flex: 1 1 168px;
min-width: 150px;
background: #fafbfe;
box-shadow: none;
}
.kid-list :deep(.dept-card:hover) {
background: #fff;
}
.kid-list :deep(.dept-card .card-name) {
font-size: 13.5px;
font-weight: 500;
}
/* 下级部门里若还有下级,占满一行表达更深层级 */
.kid-list :deep(.dept-card.has-kids) {
flex: 1 1 100%;
}
</style>
+771
View File
@@ -0,0 +1,771 @@
<template>
<div class="page-wrapper tree-wrapper dept-manage-page">
<div v-if="!roleReady" class="dept-loading">加载中…</div>
<template v-else>
<!-- 左侧:zTree 组树(超管 / 局端;只到学校层) -->
<div v-if="showTree" class="tree-container dept-manage-tree">
<GroupTreeSearchPanel class="tree-inner" ref="refTreePanel" v-model:curTreeNode="selectedNode"
:treeApi="treeApi" :defaultSearchVisible="true" @on-change="onTreeChange" />
</div>
<!-- 右侧:部门表格(白卡由主题的 .main-container 提供) -->
<div class="main-container dept-manage-main">
<div class="panel-title">
<span class="panel-caption">部门管理</span>
<Tag v-if="currentSchoolName" color="primary" class="ml-8">{{ currentSchoolName }}</Tag>
<Select v-if="multiSchool" v-model="currentSchoolId" size="small" class="ml-8 school-select">
<Option v-for="s in schoolOptions" :key="s.id" :value="s.id">{{ s.name }}</Option>
</Select>
<!-- 来源切换:只切右侧管理视图,学校上下文共用,不改底层组织树 -->
<div class="title-tail">
<span v-if="source === 'SYSTEM'" class="inc-ctrl" title="打开后列出该校下所有层级的部门"
@click="toggleInclude">
<span class="inc-label">包含子组</span>
<i class="sw-mini" :class="{ 'is-on': includeOn }"></i>
</span>
<div class="source-tabs">
<span class="source-tab" :class="{ 'is-active': source === 'SYSTEM' }"
@click="source = 'SYSTEM'">系统部门</span>
<span class="source-tab" :class="{ 'is-active': source === 'CUSTOM' }"
@click="source = 'CUSTOM'">自定义部门</span>
</div>
</div>
</div>
<template v-if="source === 'SYSTEM'">
<Alert show-icon type="success" class="usage-note">
<b>设置说明:</b>
停用只影响「选」(新建事项 / 流程绑定时选不到),不影响「看」(已有事项展示)与「办」(在途流程照常解析);
关闭「包含子组」只展示一级部门,打开后连同下级部门一并列出。
</Alert>
<Alert v-if="!currentSchoolName" show-icon type="info" class="state-tip">
{{ showTree ? '请在左侧选择学校节点' : '当前账号没有可管理的学校' }}
</Alert>
<Alert v-else-if="!deptRows.length" show-icon type="info" class="state-tip">
该校教师组下暂无部门。部门 = 教师组的直接子节点,可前往「组,用户管理」建组。
</Alert>
<template v-else>
<div class="row-sum">{{ deptRows.length }} 个一级部门 · 共 {{ flatRows.length }} 行</div>
<Table class="dept-itable" border :columns="sysColumns" :data="flatRows"
:row-class-name="rowClassName" :loading="false" no-data-text="暂无部门">
<template #name="{ row }">
<span class="nmcell" :style="{ paddingLeft: row.level * 20 + 'px' }">
<i v-if="row._kids.length" class="tw" @click.stop="toggleRow(row.id)">{{ row.open ? '▾' : '▸' }}</i>
<i v-else class="tw blank"></i>
<span class="nm" :class="{ root: row.level === 0 }" :title="row.name">{{ row.name }}</span>
<span v-if="row.kidCount" class="cnt">{{ row.kidCount }} 个下级</span>
<span v-if="row.isStudent" class="cnt grey">学生组</span>
</span>
</template>
<template #members="{ row }">
<Tooltip v-if="row.memberCount" placement="top" max-width="360" :content="memberTip(row)">
<span class="mem" :class="memberClickable(row) ? 'mem-cell' : 'mem-plain'"
@click="onMemberClick(row)">
<span class="names">{{ memberHead(row) }}</span>
<span v-if="memberRest(row)" class="more">+{{ memberRest(row) }}</span>
</span>
</Tooltip>
<span v-else class="dim">无成员</span>
</template>
<template #status="{ row }">
<i class="sw-mini" :class="{ 'is-on': row.status !== 1 }"
:title="row.status === 1 ? '点击启用' : '点击停用'"
@click="onToggle(row.raw)"></i>
</template>
</Table>
<div class="table-foot">共 {{ flatRows.length }} 个部门</div>
</template>
</template>
<CustomDeptPanel v-else :school-id="currentSchoolId" />
<!-- 系统部门人员:预览之外看全部,走 /api/sysuser/List?groupId= -->
<Modal v-model="memModal.visible" :title="`部门人员 · ${memModal.deptName}`" width="560"
:mask-closable="true" footer-hide>
<div class="mem-modal-body">
<div v-if="memModal.loading" class="mem-empty">加载中…</div>
<div v-else-if="!memModal.rows.length" class="mem-empty">该部门暂无直属成员</div>
<ul v-else class="mem-list">
<li v-for="u in memModal.rows" :key="u.id">
<span class="mname">{{ u.realName || u.account }}</span>
<span class="macc">{{ u.account }}</span>
</li>
</ul>
</div>
<div v-if="memModal.total > memModal.pageSize" class="mem-pager">
<Page :current="memModal.page" :total="memModal.total" :page-size="memModal.pageSize"
size="small" show-total @on-change="onMemPageChange" />
</div>
</Modal>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from "vue";
import { cloneDeep } from "lodash";
import Apis from "@/api";
import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
import MyConfirm from "@/components/MyConfirm";
import CustomDeptPanel from "./CustomDeptPanel.vue";
import { Message } from "view-ui-plus";
import { useDefaultStore } from "@/store";
import { UserRoleEnum } from "@/types";
/**
* 部门管理(2026-10-23 表格版)。
* 口径:部门 = 教师组(type=7)的直接子节点,下可再挂子部门(只有教师组下才可能有多级)。
* 视角:超管 / 局端 → 左侧 zTree 树只到「学校」层,右侧是该学校的部门表格;
* 学校管理员 → 无树,直接列出本校部门(多校时顶部下拉切换)。
* 表格:层级用缩进 + 折叠表达;不提供新增 / 排序 / 行内操作,只保留启停与「包含子组」。
* 人员:getTree 已带 memberCount + memberNames(前 50),超出走 /api/sysuser/List 看全部。
*/
const TEACHER_GROUP_TYPE = 7;
const STUDENT_GROUP_TYPE = 8;
const SCHOOL_GROUP_TYPE = 2;
const store = useDefaultStore();
const isSchoolMode = computed(() => store.userRole === UserRoleEnum.School);
/** 超管 / 局端才需要左侧树 */
const showTree = computed(() => !isSchoolMode.value);
/** 角色未落定前不渲染,避免先按默认角色取数再纠正 */
const roleReady = computed(() => store.roleLoaded);
const refTreePanel = ref<InstanceType<typeof GroupTreeSearchPanel>>();
const selectedNode = ref<any>({});
/** 部门来源页签:SYSTEM=原组织树视图(行为零改动);CUSTOM=学校自建分类/部门 */
const source = ref<"SYSTEM" | "CUSTOM">("SYSTEM");
/** 全量组树:右侧部门数据一律从这里按 id 回查真实 children */
const fullTree = ref<any[]>([]);
/** 当前学校:超管由树选中,学校管理员默认取可管理学校 */
const currentSchoolId = ref<number | undefined>(undefined);
const schoolIds = computed<number[]>(() => store.userScope?.schoolGroupIds ?? []);
const multiSchool = computed(() => isSchoolMode.value && schoolIds.value.length > 1);
const schoolOptions = computed(() => schoolIds.value.map((id) => ({
id,
name: findNodeById(fullTree.value, id)?.name ?? String(id),
})));
const currentSchool = computed(() => findNodeById(fullTree.value, currentSchoolId.value));
const currentSchoolName = computed(() => currentSchool.value?.name || "");
/** 折叠状态:默认全展开,只记「被收起的 id」 */
const closedIds = ref<Set<number>>(new Set());
function toggleRow(id: number) {
const next = new Set(closedIds.value);
if (next.has(id)) next.delete(id);
else next.add(id);
closedIds.value = next;
}
interface DeptRow {
id: number;
name: string;
status: number;
parentName: string;
level: number;
isStudent: boolean;
memberCount: number;
memberNames: string[];
kidCount: number;
open: boolean;
no: number;
raw: any;
/**
* 子级只给自己用。
* ⚠️ 绝不能叫 children:iView Table 发现行上有 children 就切「树形数据模式」(还要求 row-key),
* 会和这里自画的缩进 + 折叠打架,表现是整表空白(关掉包含子组后 children 为空数组才恢复)。
*/
_kids: DeptRow[];
}
/** 表格树:一级部门来自各教师组下的直接子节点,子级递归;「包含子组」关闭时不带子级 */
const deptRows = computed<DeptRow[]>(() => {
const school = currentSchool.value;
if (!school) return [];
const rows: DeptRow[] = [];
(school.children || [])
.filter((c: any) => c.type === TEACHER_GROUP_TYPE)
.forEach((t: any) => {
(t.children || []).forEach((d: any) => rows.push(toRow(d, t.name, 0)));
});
return rows;
});
function toRow(d: any, parentName: string, level: number): DeptRow {
const kids: any[] = d.children || [];
return {
id: d.id,
name: d.name,
status: d.status ?? 0,
parentName,
level,
isStudent: d.type === STUDENT_GROUP_TYPE,
memberCount: Number(d.memberCount ?? 0),
memberNames: Array.isArray(d.memberNames) ? d.memberNames : [],
// 下级数始终按真实层级给:关闭包含子组时只收起子行,徽标仍要能看出「这个部门有下级」
kidCount: kids.length,
open: !closedIds.value.has(d.id),
no: 0,
raw: d,
_kids: includeOn.value ? kids.map((k) => toRow(k, d.name, level + 1)) : [],
};
}
/** 拍平成可渲染行(按折叠状态裁剪子级),并顺序编号 */
const flatRows = computed<DeptRow[]>(() => {
const out: DeptRow[] = [];
const walk = (list: DeptRow[]) => {
list.forEach((row) => {
out.push(row);
if (row.open && row._kids.length) walk(row._kids);
});
};
walk(deptRows.value);
out.forEach((row, i) => (row.no = i + 1));
return out;
});
/** iView Table 列定义:层级用「部门名称」列的缩进 + 折叠表达,不用 Table 自带树(避免样式不可控) */
const sysColumns: any[] = [
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "部门名称", slot: "name", width: 236 },
{ title: "上级", key: "parentName", width: 130 },
{ title: "人员", slot: "members", minWidth: 200 },
{ title: "状态", slot: "status", width: 90, align: "center" },
];
/** 停用行整行灰化 */
function rowClassName(row: any) {
return row?.status === 1 ? "is-off" : "";
}
/** 人员列:最多平铺 10 个姓名,超出用「+N」提示;已全部显示(≤10 人)时不可点 */
const MEMBER_PREVIEW = 10;
function memberHead(row: DeptRow) {
// 姓名预览还没回来(后端未升级 / 未重启)时先退回人数,避免出现空单元格
if (!row.memberNames.length) return `${row.memberCount} 人`;
return row.memberNames.slice(0, MEMBER_PREVIEW).join("、");
}
function memberRest(row: DeptRow) {
if (!row.memberNames.length) return 0;
return Math.max(0, row.memberCount - Math.min(MEMBER_PREVIEW, row.memberNames.length));
}
/** 只有列不完时才值得点开弹窗(全在表格里看完了,弹窗没有增量信息) */
function memberClickable(row: DeptRow) {
return row.memberCount > MEMBER_PREVIEW;
}
function onMemberClick(row: DeptRow) {
if (memberClickable(row)) openMembers(row);
}
function memberTip(row: DeptRow) {
if (!row.memberNames.length) return `共 ${row.memberCount} 人 点击查看全部`;
const list = row.memberNames.join("、");
const more = row.memberCount > row.memberNames.length ? `…(仅预览前 ${row.memberNames.length} 人)` : "";
return `共 ${row.memberCount} 人:${list}${more}${memberClickable(row) ? " 点击查看全部" : ""}`;
}
/* ================= 人员弹窗(查看全部,分页) ================= */
const memModal = reactive({
visible: false,
loading: false,
deptId: 0,
deptName: "",
rows: [] as any[],
total: 0,
page: 1,
pageSize: 20,
});
function openMembers(row: DeptRow) {
Object.assign(memModal, {
visible: true, loading: true, deptId: row.id, deptName: row.name,
rows: [], total: 0, page: 1,
});
loadMembers();
}
function onMemPageChange(page: number) {
memModal.page = page;
loadMembers();
}
async function loadMembers() {
memModal.loading = true;
try {
const res: any = await Apis.SysUser.list({
params: { groupId: memModal.deptId, pageIndex: memModal.page, pageSize: memModal.pageSize },
cacheFor: null,
});
memModal.rows = res?.content ?? [];
memModal.total = Number(res?.total ?? 0);
} catch (e: any) {
console.warn(e?.message || "加载部门人员失败");
memModal.rows = [];
memModal.total = 0;
} finally {
memModal.loading = false;
}
}
/**
* 当前学校的「包含子组」:读学校节点上的 includeChildren(1=含子组 / 0=仅本级)。
* 历史数据或旧库该列为 null 时按「含子组」兜底,与升级前表现一致。
*/
const includeOn = computed(() => {
const school = currentSchool.value;
return school ? school.includeChildren !== 0 : false;
});
/* ================= 数据加载 ================= */
/** 树数据源:取全量树存起来给右侧用,树上只渲染到学校层 */
async function treeApi() {
const full: any[] = (await Apis.SysGroup.getTree()) || [];
fullTree.value = cloneDeep(full);
return pruneToSchoolLevel(full);
}
/** 剪枝:学校节点保留但不再下钻(超管视角树只到学校层) */
function pruneToSchoolLevel(list: any[]): any[] {
return (list || []).map((n: any) => {
const node: any = { ...n };
if (node.type === SCHOOL_GROUP_TYPE) {
delete node.children;
return node;
}
if (Array.isArray(node.children) && node.children.length) {
node.children = pruneToSchoolLevel(node.children);
}
return node;
});
}
function findNodeById(list: any[], id?: number | null): any {
if (id == null) return null;
for (const n of list || []) {
if (Number(n.id) === Number(id)) return n;
const hit = findNodeById(n.children || [], id);
if (hit) return hit;
}
return null;
}
/* ================= 交互 ================= */
function onTreeChange(node: any) {
selectedNode.value = node || {};
if (node?.type === SCHOOL_GROUP_TYPE) {
currentSchoolId.value = node.id;
closedIds.value = new Set();
} else if (node?.id) {
// 点到中间层(区域 / 学校组等)不作为当前学校
currentSchoolId.value = undefined;
}
}
/** 切换「包含子组」:落库到学校节点,成功后就地更新,避免整树重绘丢失选中态 */
async function toggleInclude() {
const school = currentSchool.value;
if (!school?.id) return;
const next = !includeOn.value;
try {
await Apis.SysGroup.setIncludeChildren({
pathParams: { id: school.id },
data: { includeChildren: next },
});
school.includeChildren = next ? 1 : 0;
closedIds.value = new Set();
Message.success(next ? "已包含下级部门" : "仅显示一级部门");
} catch (e: any) {
// 全局 alova 拦截器(alova-iview 的 onBizError/onHttpError)已统一 Message.error,此处只记日志避免二次弹窗;
// 本地字段只在写成功后改,失败即自然停在原值,无需回滚
console.warn(e?.message || "设置包含子组失败");
}
}
function onToggle(dept: any) {
if (!dept?.id) return;
if (dept.status === 1) {
doEnable(dept);
return;
}
MyConfirm({
title: "停用部门",
msg: `确定停用部门【${dept.name}】?`,
info: "停用只影响「选」(新建时选不到),不影响「看」和「办」(已有事项展示、在途流程照常解析)。",
async onOk() {
await doDisable(dept);
},
});
}
async function doDisable(dept: any) {
try {
await Apis.SysGroup.disable({ pathParams: { id: dept.id } });
Message.success("已停用");
await syncTreeData();
} catch (e: any) {
console.warn(e?.message || "停用失败");
}
}
async function doEnable(dept: any) {
try {
await Apis.SysGroup.enable({ pathParams: { id: dept.id } });
Message.success("已启用");
await syncTreeData();
} catch (e: any) {
console.warn(e?.message || "启用失败");
}
}
/**
* 静默同步组树数据:只替换右侧数据源,**不重建左侧 zTree**。<br>
* 启停改的是部门自身状态,而树只渲染到学校层,部门节点根本不在树上——
* 走 refreshTree 会让整棵树重绘、展开态与滚动位置一起丢失。
*/
async function syncTreeData() {
const full: any[] = (await Apis.SysGroup.getTree()) || [];
fullTree.value = cloneDeep(full);
}
/* ================= 初始化 ================= */
watch(() => store.roleLoaded, (loaded) => {
if (!loaded || !isSchoolMode.value) return;
loadSchoolScope();
}, { immediate: true });
/** 学校管理员:GetTree 返回的就是各学校子树(多校 = 多根) */
async function loadSchoolScope() {
const full: any[] = (await Apis.SysGroup.getTree()) || [];
fullTree.value = cloneDeep(full);
const ids = schoolIds.value;
if (ids.length) {
currentSchoolId.value = ids[0];
} else if (full.length) {
currentSchoolId.value = full[0]?.id;
}
}
</script>
<style scoped lang="scss">
.dept-manage-page {
/* 骨架交由主题 .tree-wrapper(flex + gap 16 + padding 16)+ .main-container(白卡);
这里只声明两卡等高,间距/留白一律不覆盖 */
align-items: stretch;
box-sizing: border-box;
/* 树:外层不滚(滚动交给内部 zTree),否则内容未超高也会因 1~2px 溢出出现滚动条 */
.dept-manage-tree {
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
.tree-inner {
flex: 1;
min-height: 0;
}
}
}
/* 右侧白卡内部纵向排布:标题行 / 说明条 / 表格 */
.dept-manage-main {
display: flex;
flex-direction: column;
min-height: 0;
overflow: auto;
}
.panel-title {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 12px;
}
.panel-caption {
font-size: 14px;
font-weight: 600;
color: var(--text, #1e2635);
}
/* 标题行右侧:包含子组 + 来源切换,整体右对齐;「包含子组」放左边,切视图时 tab 位置不跳 */
.title-tail {
margin-left: auto;
display: flex;
align-items: center;
gap: 14px;
}
.school-select {
width: 180px;
}
/* 来源切换:同页两个视图,不新增菜单项 */
.source-tabs {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
background: #f2f4f9;
border-radius: 8px;
}
.source-tab {
padding: 3px 10px;
font-size: 12.5px;
line-height: 18px;
border-radius: 6px;
color: var(--text-2, #5a6478);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.source-tab.is-active {
background: var(--panel, #fff);
color: var(--brand, #577afb);
font-weight: 600;
box-shadow: 0 1px 2px rgba(30, 38, 53, .08);
}
/* 面板内顶部说明条的间距(外观完全交给 iView Alert 自身,不覆写配色) */
.usage-note {
margin-bottom: 14px;
}
/* 空态 / 无权限等临时提示 */
.state-tip {
margin-bottom: 12px;
}
.dept-loading {
flex: 1;
text-align: center;
padding-top: 80px;
color: var(--text-3, #98a1b2);
}
/* 面板头的「包含子组」开关 */
.inc-ctrl {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
font-size: 12.5px;
color: var(--text-2, #5a6478);
}
/* 「包含子组」开关:面板头与 zTree 树上共用同一套样式。
树上的控件是运行时创建的 DOM,没有 scoped 标记,只能用 :deep 命中 */
:deep(.sw-mini) {
position: relative;
display: inline-block;
width: 28px;
height: 16px;
border-radius: 8px;
background: #d3d9e6;
transition: background .15s;
vertical-align: middle;
&::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 2px rgba(30, 38, 53, .2);
transition: left .15s;
}
&.is-on {
background: var(--brand, #577afb);
&::after {
left: 14px;
}
}
}
/* ================= 部门表格(iView Table) ================= */
.row-sum {
flex: 0 0 auto;
margin-bottom: 8px;
font-size: 11.5px;
color: var(--text-3, #98a1b2);
}
/* 表格本体外观全部交给 iView:只补单元格内部的排版细节 */
.dept-itable {
:deep(.ivu-table-cell) {
padding-left: 12px;
padding-right: 12px;
}
:deep(.ivu-table-row.is-off td) {
background: #fcfcfd;
color: var(--text-3, #98a1b2);
}
:deep(.ivu-table-row.is-off:hover td) {
background: #f8f9fb;
}
}
.nmcell {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.nmcell .tw {
flex: 0 0 auto;
width: 14px;
text-align: center;
font-style: normal;
font-size: 9px;
color: #aeb6c2;
cursor: pointer;
}
.nmcell .tw.blank {
visibility: hidden;
}
.nmcell .nm {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nmcell .nm.root {
font-weight: 600;
color: var(--text, #1e2635);
}
.nmcell .cnt {
flex: 0 0 auto;
padding: 0 5px;
line-height: 16px;
border-radius: 4px;
background: #f1f3f7;
color: #8a94a6;
font-size: 10.5px;
}
.nmcell .cnt.grey {
background: #f6f7fa;
color: #a3adbd;
}
/* 人员列:姓名区可截断,「+N」固定不收缩(名字太长时也得看得出还有没列完的人) */
.mem {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
max-width: 100%;
color: var(--text-2, #5a6478);
}
.mem .names {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mem .more {
flex: 0 0 auto;
font-size: 11.5px;
color: var(--text-3, #98a1b2);
}
/* 只有超过 10 人才可点(全部列完再弹窗没有增量信息) */
.mem-cell {
cursor: pointer;
}
.mem-cell:hover .names {
color: var(--brand, #577afb);
text-decoration-line: underline;
text-decoration-style: dashed;
text-underline-offset: 3px;
}
.mem-plain {
cursor: default;
}
.dim {
color: var(--text-3, #98a1b2);
font-size: 11.5px;
}
/* 状态开关:复用「包含子组」那套胶囊;表格里要跟文字垂直居中 */
.table-foot {
flex: 0 0 auto;
padding: 10px 2px;
text-align: right;
font-size: 12px;
color: var(--text-3, #98a1b2);
}
/* ================= 人员弹窗 ================= */
.mem-modal-body {
max-height: 380px;
overflow: auto;
}
.mem-empty {
padding: 26px 0;
text-align: center;
font-size: 12.5px;
color: var(--text-3, #98a1b2);
}
.mem-list {
margin: 0;
padding: 0;
list-style: none;
li {
display: flex;
align-items: center;
gap: 10px;
height: 34px;
padding: 0 4px;
border-bottom: 1px solid #f2f5f9;
font-size: 12.5px;
&:last-child {
border-bottom: 0;
}
}
.mname {
color: var(--text, #1e2635);
}
.macc {
margin-left: auto;
color: var(--text-3, #98a1b2);
font-size: 11.5px;
}
}
.mem-pager {
display: flex;
justify-content: flex-end;
padding-top: 8px;
}
</style>
+5 -5
View File
@@ -352,8 +352,8 @@ onMounted(async () => {
padding-bottom: 10px; padding-bottom: 10px;
li { li {
padding: 10px; padding: 8px;
height: 38px; height: 34px;
border-radius: 50%; border-radius: 50%;
cursor: pointer; cursor: pointer;
@@ -363,12 +363,12 @@ onMounted(async () => {
} }
svg { svg {
width: 18px; width: 16px;
height: 18px; height: 16px;
} }
.ivu-icon { .ivu-icon {
font-size: 18px; font-size: 16px;
} }
} }
+18 -42
View File
@@ -14,7 +14,7 @@
v-model="keyword" @on-search="onSearch" @on-clear="onClear" /> v-model="keyword" @on-search="onSearch" @on-clear="onClear" />
</div> </div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight"> <TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns">
<template #default="{ checkList }"> <template #default="{ checkList }">
<Button type="primary" ghost :disabled="!checkList?.length" @click="openAdjust(checkList)">调整组</Button> <Button type="primary" ghost :disabled="!checkList?.length" @click="openAdjust(checkList)">调整组</Button>
<Button type="warning" ghost class="ml-15" :disabled="!checkList?.length" @click="openBatchStatus(checkList)">设置状态</Button> <Button type="warning" ghost class="ml-15" :disabled="!checkList?.length" @click="openBatchStatus(checkList)">设置状态</Button>
@@ -59,7 +59,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import { Message } from "view-ui-plus"; import { Message, Tag, Badge } from "view-ui-plus";
import useCurrentInstance from "@/utils/hook/useCurrentInstance"; import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import MyConfirm from "@/components/MyConfirm"; import MyConfirm from "@/components/MyConfirm";
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue'; import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
@@ -70,10 +70,11 @@ import EditUserContainer from './user/EditUserContainer.vue';
import AdjustGroupContainer from './AdjustGroupContainer.vue'; import AdjustGroupContainer from './AdjustGroupContainer.vue';
import BatchImportUser from './user/BatchImportUser.vue'; import BatchImportUser from './user/BatchImportUser.vue';
import UserSort from './user/UserSort.vue'; import UserSort from './user/UserSort.vue';
import { actionColumn } from "@/utils/tableActions";
defineOptions({ name: "GroupUser" }); defineOptions({ name: "GroupUser" });
const { $utils, $conf } = useCurrentInstance(); const { $conf } = useCurrentInstance();
const treeApi = () => Apis.SysGroup.getTree(); const treeApi = () => Apis.SysGroup.getTree();
@@ -146,8 +147,6 @@ const tableApi = (params: { pageIndex: number; pageSize: number }) => {
}); });
}; };
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }))
/* ================= 表格列 ================= */ /* ================= 表格列 ================= */
const columns = [ const columns = [
{ type: 'selection', width: 55, align: 'center' }, { type: 'selection', width: 55, align: 'center' },
@@ -164,49 +163,30 @@ const columns = [
{ {
title: '状态', key: 'disabled', width: 130, align: 'center', title: '状态', key: 'disabled', width: 130, align: 'center',
render: (h: any, { row }: any) => { render: (h: any, { row }: any) => {
if (row.disabled) return h('Tag', { color: 'default' }, '禁用'); // Vue3 渲染函数须 import 真组件:h('Tag') 会建未知原生元素(无样式不渲染)
// 描边徽标挂主题令牌(.tag-outline,见 main.scss):预设色 border prop 渲染仍实心,弃用
if (row.disabled) return h(Tag, { class: 'tag-outline tag-outline-muted' }, () => '禁用');
if (row.groupStatus === 1) { if (row.groupStatus === 1) {
// 正常 Tag + 转出徽标(view-ui-plus Badge status 模式:圆点 + 文本) // 正常徽标 + 转出角标(view-ui-plus Badge status 模式:圆点 + 文本)
return h('div', { style: 'display:inline-flex;align-items:center;gap:6px' }, [ return h('div', { style: 'display:inline-flex;align-items:center;gap:6px' }, [
h('Tag', { color: 'success' }, '正常'), h(Tag, { class: 'tag-outline' }, () => '正常'),
h('Badge', { props: { status: 'warning', text: '转出' } }), h(Badge, { status: 'warning', text: '转出' }),
]); ]);
} }
return h('Tag', { color: 'success' }, '正常'); return h(Tag, { class: 'tag-outline' }, () => '正常');
}, },
}, },
{ {
title: '排序号', key: 'sortNo', width: 90, align: 'center', title: '排序号', key: 'sortNo', width: 90, align: 'center',
render: (h: any, { row }: any) => h('span', row.sortNo == null ? '-' : row.sortNo), render: (h: any, { row }: any) => h('span', row.sortNo == null ? '-' : row.sortNo),
}, },
{ // 文字链形态:颜色走通道 .operating-link = var(--brand),随主题档位变化;
title: '操作', key: 'action', width: 220, align: 'center', fixed: 'right' as const, // 排序仅「单组浏览」开放——withSub 是与行数据无关的全局模式开关,用 when 整组隐藏合规
render: (h: any, { row }: any) => { actionColumn([
const opList: any[] = [ { key: 'edit', label: '编辑', on: (r: any) => handleEdit(r) },
h('a', { { key: 'sort', label: '排序', on: (r: any) => openSort(r), when: () => dataParams.value.withSub === 0 },
style: { marginRight: '8px', color: '#577afb' }, { key: 'del', label: '删除', on: (r: any) => handleDelete(r), danger: true },
onClick: () => handleEdit(row), ], { mode: 'text', width: 180, fixed: 'right' }),
}, '编辑'),
];
// 仅「单组浏览」(withSub=0)时开放排序:此时每个用户都直接属于当前组,排序号语义明确;
// 含子组搜索(withSub=1)下用户可能属于子组,排序号归属不清,故隐藏。
if (dataParams.value.withSub === 0) {
opList.push(
h('a', {
style: { marginRight: '8px', color: '#2d8cf0' },
onClick: () => openSort(row),
}, '排序'),
);
}
opList.push(
h('a', {
style: { color: '#ed4014' },
onClick: () => handleDelete(row),
}, '删除'),
);
return h('div', opList);
},
},
]; ];
/* ================= 导出组用户 ================= */ /* ================= 导出组用户 ================= */
@@ -368,10 +348,6 @@ function onSortSaved() {
</script> </script>
<style lang="scss"> <style lang="scss">
.group-user-page .page-panel {
left: 348px !important;
}
/* 左侧树容器改为 flex 纵向布局并收起自身滚动,把滚动条收敛到树节点区域 /* 左侧树容器改为 flex 纵向布局并收起自身滚动,把滚动条收敛到树节点区域
(滚动体是 MyTree 内部的 .ztree,见 components/MyTree.vue), (滚动体是 MyTree 内部的 .ztree,见 components/MyTree.vue),
避免树过长时把顶部 opera-ul 操作按钮条滚出屏幕;仅作用于本页,不影响全局 .tree-container */ 避免树过长时把顶部 opera-ul 操作按钮条滚出屏幕;仅作用于本页,不影响全局 .tree-container */
+2 -2
View File
@@ -158,10 +158,10 @@ async function handlePreview() {
} }
.download-link { .download-link {
color: #577afb; color: var(--brand);
&:hover { &:hover {
color: #3d5afe; color: var(--brand-2);
} }
} }
+7 -19
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="main-wrapper"> <div class="page-wrapper yh-fill">
<div class="main-container"> <div class="main-container">
<div class="search-wrap fix"> <div class="search-wrap fix">
<Input v-model="dataParams.keyword" placeholder="输入接口名/应用名/appKey搜索" clearable suffix="ios-search" <Input v-model="dataParams.keyword" placeholder="输入接口名/应用名/appKey搜索" clearable suffix="ios-search"
@@ -26,7 +26,7 @@
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button> <Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
</div> </div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" /> <TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
</div> </div>
<OapiLogDetailModal v-model="detailVisible" :row="currentRow" /> <OapiLogDetailModal v-model="detailVisible" :row="currentRow" />
@@ -35,15 +35,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue'; import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import OapiLogDetailModal from './OapiLogDetailModal.vue'; import OapiLogDetailModal from './OapiLogDetailModal.vue';
import Utils from '@/utils'; import Utils from '@/utils';
import { actionColumn } from '@/utils/tableActions';
defineOptions({ name: 'OapiLogList' }); defineOptions({ name: 'OapiLogList' });
const { $utils } = useCurrentInstance();
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null); const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
interface DataParams { interface DataParams {
@@ -98,8 +96,6 @@ const tableApi = (params: { pageIndex: number; pageSize: number }) => {
}); });
}; };
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }));
const logLevelMap: Record<number, { text: string; className: string }> = { const logLevelMap: Record<number, { text: string; className: string }> = {
1: { text: '重要', className: 'text-error' }, 1: { text: '重要', className: 'text-error' },
2: { text: '次要', className: 'text-warning' }, 2: { text: '次要', className: 'text-warning' },
@@ -164,18 +160,10 @@ const columns = [
width: 180, width: 180,
render: (h: any, { row }: any) => h('span', row.createTime ? Utils.dateFormat(row.createTime, 'yyyy-MM-dd HH:mm:ss') : '-'), render: (h: any, { row }: any) => h('span', row.createTime ? Utils.dateFormat(row.createTime, 'yyyy-MM-dd HH:mm:ss') : '-'),
}, },
{ // 文字链形态:颜色走通道 .operating-link = var(--brand),随主题档位变化
title: '操作', actionColumn([
key: 'action', { key: 'detail', label: '查看详情', on: (r: any) => openDetail(r) },
width: 100, ], { mode: 'text', width: 100, fixed: 'right' }),
align: 'center',
fixed: 'right' as const,
render: (h: any, { row }: any) =>
h('a', {
style: { color: '#577afb' },
onClick: () => openDetail(row),
}, '查看详情'),
},
]; ];
const detailVisible = ref(false); const detailVisible = ref(false);
+2 -2
View File
@@ -150,10 +150,10 @@ async function handlePreview() {
} }
.download-link { .download-link {
color: #577afb; color: var(--brand);
&:hover { &:hover {
color: #3d5afe; color: var(--brand-2);
} }
} }
} }
+8 -20
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="main-wrapper"> <div class="page-wrapper yh-fill">
<div class="main-container"> <div class="main-container">
<div class="search-wrap fix"> <div class="search-wrap fix">
<Input v-model="dataParams.keyword" placeholder="接口名称 / 用户名" clearable suffix="ios-search" <Input v-model="dataParams.keyword" placeholder="接口名称 / 用户名" clearable suffix="ios-search"
@@ -27,7 +27,7 @@
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button> <Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
</div> </div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" /> <TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
</div> </div>
<OperationLogDetailModal v-model="detailVisible" :row="currentRow" /> <OperationLogDetailModal v-model="detailVisible" :row="currentRow" />
@@ -36,15 +36,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue'; import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import OperationLogDetailModal from './OperationLogDetailModal.vue'; import OperationLogDetailModal from './OperationLogDetailModal.vue';
import Utils from '@/utils'; import Utils from '@/utils';
import { actionColumn } from '@/utils/tableActions';
defineOptions({ name: 'OperationLogList' }); defineOptions({ name: 'OperationLogList' });
const { $utils } = useCurrentInstance();
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null); const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
interface DataParams { interface DataParams {
@@ -99,8 +97,6 @@ const tableApi = (params: { pageIndex: number; pageSize: number }) => {
}); });
}; };
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }));
const logLevelMap: Record<number, { text: string; className: string }> = { const logLevelMap: Record<number, { text: string; className: string }> = {
1: { text: '重要', className: 'text-error' }, 1: { text: '重要', className: 'text-error' },
2: { text: '次要', className: 'text-warning' }, 2: { text: '次要', className: 'text-warning' },
@@ -160,23 +156,15 @@ const columns = [
render: (h: any, { row }: any) => h('span', row.responseSize != null && row.responseSize > 0 ? Utils.getFileSize(row.responseSize) : '-'), render: (h: any, { row }: any) => h('span', row.responseSize != null && row.responseSize > 0 ? Utils.getFileSize(row.responseSize) : '-'),
}, },
{ {
title: '操作时间ed', title: '操作时间',
key: 'createTime', key: 'createTime',
width: 180, width: 180,
render: (h: any, { row }: any) => h('span', row.createTime ? Utils.dateFormat(row.createTime, 'yyyy-MM-dd HH:mm:ss') : '-'), render: (h: any, { row }: any) => h('span', row.createTime ? Utils.dateFormat(row.createTime, 'yyyy-MM-dd HH:mm:ss') : '-'),
}, },
{ // 文字链形态:颜色走通道 .operating-link = var(--brand),随主题档位变化
title: '操作', actionColumn([
key: 'action', { key: 'detail', label: '查看详情', on: (r: any) => openDetail(r) },
width: 100, ], { mode: 'text', width: 100, fixed: 'right' }),
align: 'center',
fixed: 'right' as const,
render: (h: any, { row }: any) =>
h('a', {
style: { color: '#577afb' },
onClick: () => openDetail(row),
}, '查看详情'),
},
]; ];
const detailVisible = ref(false); const detailVisible = ref(false);
+8 -32
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="main-wrapper"> <div class="page-wrapper yh-fill">
<div class="main-container"> <div class="main-container">
<div class="search-wrap fix"> <div class="search-wrap fix">
<Select v-model="dataParams.appKey" class="ml-12" clearable filterable placeholder="应用" <Select v-model="dataParams.appKey" class="ml-12" clearable filterable placeholder="应用"
@@ -27,7 +27,7 @@
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button> <Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
</div> </div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" /> <TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
</div> </div>
<PushLogDetailModal v-model="detailVisible" :row="currentRow" /> <PushLogDetailModal v-model="detailVisible" :row="currentRow" />
@@ -37,15 +37,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { Message } from 'view-ui-plus'; import { Message } from 'view-ui-plus';
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue'; import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import Utils from '@/utils'; import Utils from '@/utils';
import PushLogDetailModal from './PushLogDetailModal.vue'; import PushLogDetailModal from './PushLogDetailModal.vue';
import { actionColumn } from '@/utils/tableActions';
defineOptions({ name: 'PushLog' }); defineOptions({ name: 'PushLog' });
const { $utils } = useCurrentInstance();
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null); const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
const interfaceOptions = [ const interfaceOptions = [
@@ -113,8 +111,6 @@ const tableApi = (params: { pageIndex: number; pageSize: number }) => {
}); });
}; };
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }));
const columns = [ const columns = [
{ type: 'index', title: '序号', width: 70, align: 'center' }, { type: 'index', title: '序号', width: 70, align: 'center' },
{ title: '应用', key: 'appName', ellipsis: true, tooltip: true }, { title: '应用', key: 'appName', ellipsis: true, tooltip: true },
@@ -163,23 +159,11 @@ const columns = [
align: 'right', align: 'right',
render: (h: any, { row }: any) => h('span', row.payloadSize != null && row.payloadSize > 0 ? Utils.getFileSize(row.payloadSize) : '-'), render: (h: any, { row }: any) => h('span', row.payloadSize != null && row.payloadSize > 0 ? Utils.getFileSize(row.payloadSize) : '-'),
}, },
{ // 重试按行数据(成功与否)变化 → disabledWhen 置灰保位(§5.2 按钮位置稳定性红线),禁止 when 隐藏
title: '操作', actionColumn([
key: 'action', { key: 'detail', label: '查看详情', on: (r: any) => openDetail(r) },
width: 150, { key: 'retry', label: '重试', on: (r: any) => handleRetry(r), danger: true, disabledWhen: (r: any) => r.success === 1 },
align: 'left', ], { mode: 'text', width: 150 }),
render: (h: any, { row }: any) => {
const actions = [
h('a', { style: { color: '#577afb', marginRight: '12px' }, onClick: () => openDetail(row) }, '查看详情'),
];
if (row.success !== 1) {
actions.push(
h('a', { style: { color: '#ed4014' }, onClick: () => handleRetry(row) }, '重试')
);
}
return h('span', actions);
},
},
]; ];
const detailVisible = ref(false); const detailVisible = ref(false);
@@ -225,12 +209,4 @@ loadApps();
vertical-align: middle; vertical-align: middle;
} }
} }
.text-success {
color: #19be6b;
}
.text-error {
color: #ed4014;
}
</style> </style>
+2 -2
View File
@@ -169,10 +169,10 @@ function formatTime(val: any): string {
} }
.download-link { .download-link {
color: #577afb; color: var(--brand);
&:hover { &:hover {
color: #3d5afe; color: var(--brand-2);
} }
} }
} }
+34 -11
View File
@@ -64,7 +64,7 @@ type AppModuleVo = {
checked?: boolean; checked?: boolean;
children?: AppModuleVo[]; children?: AppModuleVo[];
}; };
type AppItem = { id?: number; name?: string; sortNo?: number; modules?: AppModuleVo[] }; type AppItem = { id?: number; name?: string; sortNo?: number; checked?: boolean; modules?: AppModuleVo[] };
type CategoryItem = { id?: number; name?: string; apps?: AppItem[] }; type CategoryItem = { id?: number; name?: string; apps?: AppItem[] };
type AuthzTreeNode = { type AuthzTreeNode = {
id: string; id: string;
@@ -118,12 +118,16 @@ function countTotalModules(nodes: AppModuleVo[]): number {
} }
function isAppChecked(app: AppItem): boolean { function isAppChecked(app: AppItem): boolean {
const modules = app.modules || []; const modules = app.modules || [];
if (modules.length === 0) return false; // 无子模块应用(如「一周安排」):勾选态来自后端返回的整应用授权记录
return modules.every((m) => isModuleAllChecked(m)); if (modules.length === 0) return !!app.checked;
// 父级展示口径:只要有一个子节点被勾选就算选中(不采用 zTree 半选,见样式里 true_part 的覆写)
return modules.some((m) => isModuleAnyChecked(m));
} }
function isModuleAllChecked(node: AppModuleVo): boolean {
if (!node.checked) return false; /** 自身或任一子孙被勾选 */
return (node.children || []).every((c) => isModuleAllChecked(c)); function isModuleAnyChecked(node: AppModuleVo): boolean {
if (node.checked) return true;
return (node.children || []).some((c) => isModuleAnyChecked(c));
} }
// 空 children 不挂键:zTree 只要见到 children 是数组就强制 isParent=true 而渲染 +/−(叶子也会出现展开图标) // 空 children 不挂键:zTree 只要见到 children 是数组就强制 isParent=true 而渲染 +/−(叶子也会出现展开图标)
function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode { function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
@@ -132,7 +136,7 @@ function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
id: `mod_${m.id}`, id: `mod_${m.id}`,
name: String(m.name || ""), name: String(m.name || ""),
open: true, open: true,
checked: !!m.checked, checked: isModuleAnyChecked(m),
nodeType: "module", nodeType: "module",
rawId: Number(m.id), rawId: Number(m.id),
appId, appId,
@@ -255,7 +259,12 @@ function onTreeCheck(node: any) {
// zTree 原生级联已生效,把该应用最终勾选态同步回本地数据(saveNow 的提交依据) // zTree 原生级联已生效,把该应用最终勾选态同步回本地数据(saveNow 的提交依据)
const app = findLocalApp(node.appId); const app = findLocalApp(node.appId);
if (!app) return; if (!app) return;
syncAppModulesFromTree(app, node); if ((app.modules || []).length === 0) {
// 无子模块应用:没有模块明细可同步,直接记整应用勾选态
app.checked = !!node.checked;
} else {
syncAppModulesFromTree(app, node);
}
scheduleSave(); scheduleSave();
} }
@@ -265,7 +274,14 @@ const saveNow = async () => {
const moduleIds: number[] = []; const moduleIds: number[] = [];
for (const cat of categoryList.value || []) { for (const cat of categoryList.value || []) {
for (const app of cat.apps || []) { for (const app of cat.apps || []) {
walkModules(app.modules || [], (n) => { if (n.checked) moduleIds.push(Number(n.id)); }); const mods = app.modules || [];
if (mods.length === 0) {
// 无子模块应用:用 -appId 哨兵表示「整应用授权」(后端 AppLevelModuleId 约定),
// 否则这类应用永远收集不到 id、保存时被整表覆盖清掉。
if (app.checked) moduleIds.push(-Number(app.id));
continue;
}
walkModules(mods, (n) => { if (n.checked) moduleIds.push(Number(n.id)); });
} }
} }
try { try {
@@ -299,7 +315,7 @@ watch(
.role-authz-tree { .role-authz-tree {
position: relative; position: relative;
flex: 1; flex: 1;
min-height: 320px; min-height: 0;
padding: 0; padding: 0;
background: #fff; background: #fff;
overflow: auto; overflow: auto;
@@ -343,7 +359,7 @@ watch(
} }
.role-name { .role-name {
color: #2d8cf0; color: var(--brand);
font-weight: 600; font-weight: 600;
} }
@@ -441,6 +457,13 @@ watch(
text-align: center; text-align: center;
} }
/* 父级勾选样式(2026-10-20):把 zTree 半选状态位 true_part 的图标换成 true_full 的蓝底勾,
让「只要有一个子节点被选中」的父节点统一呈现为选中态(与组授权页口径一致)。 */
:deep(.ztree li span.button.chk.checkbox_true_part),
:deep(.ztree li span.button.chk.checkbox_true_part_focus) {
background-position: -26px -5px;
}
:deep(.ztree li a) { :deep(.ztree li a) {
max-width: calc(100% - 30px); max-width: calc(100% - 30px);
overflow: hidden; overflow: hidden;
+31 -23
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="page-wrapper role-authz-wrapper"> <div class="page-wrapper tree-wrapper role-authz-wrapper">
<!-- 左:角色列表(可滚动,应对大量角色;顶部 2 个筛选) --> <!-- 左:角色列表(可滚动,应对大量角色;顶部 2 个筛选) -->
<div class="role-list-panel"> <div class="tree-container role-list-panel">
<div class="role-filter"> <div class="role-filter">
<Select v-model="filterAppId" placeholder="按绑定应用筛选" filterable clearable <Select v-model="filterAppId" placeholder="按绑定应用筛选" filterable clearable
@on-change="onFilterChange"> @on-change="onFilterChange">
@@ -18,7 +18,7 @@
:class="['role-item', selectedRoleId === role.id ? 'active' : '']" @click="selectRole(role)"> :class="['role-item', selectedRoleId === role.id ? 'active' : '']" @click="selectRole(role)">
<div class="role-name">{{ role.name }}</div> <div class="role-name">{{ role.name }}</div>
<div class="role-app"> <div class="role-app">
<Tag v-if="role.allAppFlag" color="blue" size="small">全应用</Tag> <Tag v-if="role.allAppFlag" class="tag-brand" border size="small">全应用</Tag>
<span v-else>{{ role.appName || "-" }}</span> <span v-else>{{ role.appName || "-" }}</span>
</div> </div>
</div> </div>
@@ -31,12 +31,12 @@
</div> </div>
<!-- 右:选中角色的授权面板(应用授权 / 用户管理) --> <!-- 右:选中角色的授权面板(应用授权 / 用户管理) -->
<div class="authz-panel"> <div class="main-container authz-panel">
<div v-if="selectedRole" class="authz-inner"> <div v-if="selectedRole" class="authz-inner">
<div class="authz-bar"> <div class="authz-bar">
<span class="authz-title">当前角色:<b>{{ selectedRole.name }}</b></span> <span class="authz-title">当前角色:<b>{{ selectedRole.name }}</b></span>
<span class="authz-app"> <span class="authz-app">
<Tag v-if="selectedRole.allAppFlag" color="blue">全应用</Tag> <Tag v-if="selectedRole.allAppFlag" class="tag-brand" border>全应用</Tag>
<span v-else>{{ selectedRole.appName || "-" }}</span> <span v-else>{{ selectedRole.appName || "-" }}</span>
</span> </span>
<Tag v-if="selectedRole.disabled" color="red">已禁用</Tag> <Tag v-if="selectedRole.disabled" color="red">已禁用</Tag>
@@ -152,19 +152,12 @@ function goManage() {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.role-authz-wrapper { /* 通道 tree-wrapper 骨架:外边界 16 / 两卡间隙 16 / 等高 stretch,高度对齐列表页 */
display: flex;
height: 100%;
background: #f5f7fa;
}
.role-list-panel { .role-list-panel {
width: 300px; width: 300px;
flex-shrink: 0; padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #ffffff;
border-right: 1px solid #ebedf0;
overflow: hidden; overflow: hidden;
} }
@@ -196,12 +189,12 @@ function goManage() {
transition: all 0.15s; transition: all 0.15s;
&:hover { &:hover {
border-color: #b8c6ff; border-color: var(--brand-2);
} }
&.active { &.active {
border-color: #577afb; border-color: var(--brand);
background: #f3f6ff; background: var(--brand-soft);
} }
} }
@@ -235,18 +228,21 @@ function goManage() {
border-top: 1px solid #eeeeee; border-top: 1px solid #eeeeee;
.ivu-btn-text { .ivu-btn-text {
color: #577afb; color: var(--brand);
} }
} }
.authz-panel { .authz-panel {
flex: 1; display: flex;
min-width: 0; flex-direction: column;
overflow: auto; overflow: hidden;
padding: 0 16px;
} }
.authz-inner { .authz-inner {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding-top: 12px; padding-top: 12px;
} }
@@ -255,10 +251,18 @@ function goManage() {
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 8px 0 4px; padding: 8px 0 4px;
flex: none;
} }
.authz-title b { .authz-title b {
color: #577afb; color: var(--brand);
}
/* 主题色描边徽标(iView color 预设是写死蓝,改挂通道令牌随档变化) */
.tag-brand {
color: var(--brand) !important;
border: 1px solid var(--brand) !important;
background: #fff;
} }
.authz-app { .authz-app {
@@ -268,9 +272,13 @@ function goManage() {
.authz-tabs { .authz-tabs {
margin-top: 6px; margin-top: 6px;
flex: none;
} }
.authz-tab-content { .authz-tab-content {
flex: 1;
min-height: 0;
overflow: auto;
padding-top: 8px; padding-top: 8px;
} }
</style> </style>
+2 -2
View File
@@ -158,10 +158,10 @@ function goAuthz() {
text-align: center; text-align: center;
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
color: #577afb; color: var(--brand);
&:hover { &:hover {
color: #6c91f0; color: var(--brand-2);
} }
} }
+5 -7
View File
@@ -16,11 +16,9 @@
import { ref, computed, watch } from "vue"; import { ref, computed, watch } from "vue";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import Apis from "@/api"; import Apis from "@/api";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import MyConfirm from "@/components/MyConfirm"; import MyConfirm from "@/components/MyConfirm";
import RoleUserBindModal from "./RoleUserBindModal.vue"; import RoleUserBindModal from "./RoleUserBindModal.vue";
import { actionColumn } from "@/utils/tableActions";
const { $utils } = useCurrentInstance();
const props = defineProps<{ const props = defineProps<{
roleId: number; roleId: number;
@@ -44,10 +42,10 @@ const columns = [
{ title: "账号", key: "account", width: 110 }, { title: "账号", key: "account", width: 110 },
{ title: "姓名", key: "realName", width: 90 }, { title: "姓名", key: "realName", width: 90 },
{ title: "部门", key: "groupPath", minWidth: 200 }, { title: "部门", key: "groupPath", minWidth: 200 },
{ // UserBase 恒文字链(工程风格显式声明):单删除动作保持红色文字链,列宽 80 不变
title: "操作", key: "action", width: 80, align: "center", actionColumn([
render: (h: any, { row }: any) => $utils.operatingTextHandle(h, "删除", "text-error", () => removeUser(row)), { key: "del", label: "删除", icon: "icon-ashbin", on: (r: any) => removeUser(r), danger: true },
}, ], { mode: "text", width: 80 }),
]; ];
async function loadUsers() { async function loadUsers() {