Compare commits

...
56 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
chenxing 0a93441f64 chore(deps): view-ui-plus lock 对齐 1.3.24(YschTheme 通道批次0 前置,版本锁测试解锁)
build / build (push) Successful in 3m51s
2026-10-07 23:35:46 +08:00
chenxing b1dba84c49 feat(appicon): 应用图标 AppIcon 矢量选择器接入(YschAppIcons 资产库 10-04 轮)
- src/assets/appicon-icons.json:资产库图标清单
- src/components/AppIconPicker.vue:矢量图标+配色选择器(YschAppIcons mask 资产)
- AppForm.vue:平台模式 Pc 应用图标换 AppIconPicker(学校模式保留位图上传,学校覆盖线只收 pcIcon);App 位图图标移入高级折叠区(遗留通道,存量 pc_icon/app_icon 链路保留,新应用优先矢量图标键)
- AddAppContainer/EditAppContainer:配套接线
依据:根目录《AppIcon资产库落地-执行报告-20261004.md》P2 管理端图标选择器
2026-10-06 20:16:21 +08:00
chenxing efd55a3a75 feat(avatar): 顶栏三态头像接入(YschTheme 默认头像轮 sync 件)
- src/utils/avatar.ts + src/components/UserAvatar.vue:YschTheme sync 下发公共源(取值链 URL > yh-avatar > yh-avatar-sch-default > text,值域 text|silhouette)
- layout.vue:顶栏 img 头像换 UserAvatar 组件(placement=brand,size=28);头像/姓名点击统一跳 UserHome 设置页「个人信息」pane(原 /v/userinfo 已下线语义)
2026-10-06 20:16:20 +08:00
chenxing 5296b5999c feat(theme): A 类免插件接入 YschTheme 中央静态通道(index.html loader + 删本地 layout.css)
- index.html:env-config 引导后插主题通道 loader(优先级 staticHost > baseHost > %VITE_THEME_HOST% 仅 dev > 同源 /static,document.write parser-blocking,产物占位符零烧入)
- main.ts:删本地 layout.css 引入(通道旧版全量拷贝 14.8KB),注释改指通道下发链
- src/assets/styles/layout.css:删除(staged)
- vite.config.ts:/static dev 代理(YH_THEME_PROXY_TARGET 可覆盖)+ /api /mobiapi 代理整理
- .env.dev:三种 dev 模式说明 + VITE_THEME_HOST 连法注释
- App.vue:注释纠正(主题样式改由中央通道下发)
- alova-iview/alova-vant:修 mock 失效(baseUrl 未认 isMock)
依据:YschTheme 方案 §11 A 类契约 +《主题通道当前困境与待决问题-20260929》§3 九文件清单
2026-10-06 20:16:03 +08:00
chenxing ec53c34b9c feat(app): 基石应用删除解禁——MyConfirm 同步 projman 删除模(输名确认+红色确认键),删除一律危险确认;基石模块/推送仍置灰
build / build (push) Successful in 3m47s
2026-09-29 15:26:25 +08:00
chenxing e034b947e5 fix(app): 性质/技术标识提示改 md-help 图标+Button 触发的 Tooltip(max-width=320 防截断);基石应用整行隐藏 Pc 地址/图标(无消费方)
build / build (push) Successful in 12m52s
2026-09-29 14:47:21 +08:00
chenxing e1aee91a98 feat(app): 基石应用隐藏门户投放字段(前端类型/PC·App地址图标/公众号/钉钉);性质说明改 Tooltip 防折行;技术标识加 kebab-case 提示与校验
build / build (push) Successful in 3m38s
2026-09-29 11:39:59 +08:00
chenxing 14dcdb7fe1 feat(app): 编辑应用回传 appCode/appNature 必填配套(C5)——AppForm 编辑态技术标识必填规则(后端 Create/Update 组同口径);EditAppContainer 学校分支补回传 categoryId/appCode/appNature 原值满足平台校验(后端学校覆盖只读名称/描述/图标,语义不变)
build / build (push) Successful in 2m24s
2026-09-28 17:08:12 +08:00
chenxing 6a2c82c0ef feat(app): 应用表单加性质 Switch+技术标识,列表加性质列且基石行模块/推送/删除置灰(消息卡片方案 B1) 2026-09-28 12:44:45 +08:00
chenxing 0080861482 fix(authz): 组授权卡片计数改贴整卡右下角,与子组继承复选框同列
build / build (push) Successful in 1m51s
- 「N个应用」从卡片头移入 cat-card-body 末尾,绝对定位到整卡右下角
- .cat-count 的 right 与子组继承复选框同值 14px,上下对齐成一列
- cat-card-body 底部 padding 加到 24px 腾出计数位置;卡片头 padding 恢复 8px 12px
2026-09-23 09:29:32 +08:00
chenxing 02a203a7f1 fix(authz): 组授权卡片头计数下沉右下角,子组继承回归最右
build / build (push) Successful in 2m29s
- 「N个应用」改为绝对定位到卡片头右下角,不再参与主行 flex 排布
- 主行右侧顺序调整:全部展开 → 子组继承(居最右)
- 卡片头底部 padding 加到 26px 容纳角标;分类名收缩系数收敛为 flex: 0 1 auto
2026-09-23 09:24:46 +08:00
chenxing 71c08d6e9a feat(authz): 权限一览加「全部展开」开关与卡片布局加固;下线测试推送页
build / build (push) Successful in 2m3s
- 权限一览:应用层默认折叠,新增「全部展开」开关(事件不冒泡到标题行),MyTree 透传 expand-all
- 应用模块树卡片:分类名与数量让位(超大收缩系数 + min-width:0),「子组继承」加 tooltip 与弹性空隙,
  「全部展开」加标签样式与竖线分隔,避免整组被挤出可视区或被读成同一个开关
- 下线测试推送:删除 /push-test 路由、菜单项与 PushTest.vue
2026-09-18 11:13:36 +08:00
chenxing 7784492522 chore(types): 同步用户身份字段(删 SysUserVo.roles,identityNames 口径注释订正)
- SysUserVo.roles 已停写(后端本次一并删除该字段),前端类型同步移除
- identityNames 注明:组上下文列表只含当前组范围解析出的学校下的身份(与编辑弹窗「所在组」同源)
2026-09-18 11:13:27 +08:00
chenxing d012a99e28 feat(log): 三类日志列表加大小列,详情按 50KB 门槛切换在线预览/下载
build / build (push) Successful in 1m57s
- 推送/操作/开放接口日志列表各加"大小"列(payload_size/response_size,Utils.getFileSize 格式化)
- 详情弹窗:下载按钮常驻;Utils.canPreview(≤50KB) 才显示"在线预览",点击 fetch 复用各自 /Download/{id} 取原文渲染
- oapilog 详情从无条件内联改为同一套门槛逻辑
- utils 新增 PREVIEW_MAX_BYTES 与 canPreview
2026-09-18 09:24:38 +08:00
chenxing 628a914e19 feat(authz): 应用类型卡片新增「全部展开」开关 + 用户管理列宽让位部门列
build / build (push) Successful in 3m16s
- MyTree 新增受控 prop expandAll(默认 false):在 zTree 初始化之后套用并监听变化,
  数据重载(切组/搜索过滤)后自动重新展开,规避调用方与 initTree 抢 nextTick 时序
- 组授权 / 角色授权的应用类型卡片头部加「全部展开 + small Switch」,默认关闭,
  控制该类型下全部应用与模块的展开收起;每个卡片独立,状态按 category id 保存
- 开关外层 @click.stop:卡片头部本身绑定 toggleCollapse,不阻断冒泡会连带折叠卡片
- 角色授权「用户管理」表格:账号/姓名改定宽(110/90)、操作 80,部门 minWidth 吃剩余
  宽度,把空间留给组路径列
2026-09-16 19:14:23 +08:00
chenxing a082e59c3e fix(tree): 修复左侧树滚动条失效与叶子节点误显 +/−,统一权限一览连接线
build / build (push) Successful in 3m27s
滚动条(上次改动引入的回归):
- MyTree 根节点加 .my-tree 类并打通高度链(flex 纵向 + .ztree 承担滚动),
  父级定高时滚动条只落在树节点区域;父级 auto 的卡片内树行为不变
- GroupTreeSearchPanel 的 .tree-body 改 flex 纵向传递高度,删除已失效的 .ztree 规则

叶子节点 +/−(以组用户管理左侧树为参考统一):
- zTree 只要 children 是数组即强制 isParent=true,故空数组必须不挂键;
  修正 GroupAuth / AppModuleCheckTree / PermissionView 共 9 处节点构造
- MyTree 初始化前加节点规范化兜底,防止后续新增树重犯
- removeNode 删除末个子节点后同步父节点父/叶状态,不再残留 +/−

权限一览:
- 删除 li{line-height:26px} 覆盖(zTree 图标按 21px 节距,26px 会让连接线出现断口)
- 左列改用页面级 permission-tree 类:flex 纵向 + overflow:hidden,
  消除整列滚动与外层裁切,滚动收敛到树体
2026-09-16 17:46:06 +08:00
chenxing af94e1d2cb fix(permission): 角色节点联动已授权应用、所属部门按行收窄、树滚动条收敛
build / build (push) Successful in 2m53s
- 权限一览:formatRoleAuthzTree 子节点键 apps→children(zTree 只认 children,导致除分类外全不渲染)
- 权限一览:所属部门按行所在组请求 UserGroupTree,多组用户只显示该组链路,(本组)标记指向该组
- 角色授权:用户管理新增「部门」列,添加/删除按(用户,组)维度,切组清空勾选避免误绑
- 编辑用户:切「所在组」时身份与角色属性双回显,该组角色未就绪拦截提交防误存;保存按组覆盖
- 组用户管理:左侧树滚动条收敛到树节点(页面级新增 group-user-tree 类,不动全局 .tree-container)
2026-09-16 16:33:58 +08:00
chenxing 4237615238 权限一览修订
build / build (push) Successful in 3m40s
2026-09-16 09:44:54 +08:00
chenxing 711765234f feat(app-module): 模块管理新增图标字段的表格列与表单行
build / build (push) Successful in 1m56s
配合后端 app_module.icon(YschIcons 图标名,非必填):
- 表格在「模块名称」后加「图标」列,有值时预览 SvgIcon 并显示图标名原文,无值显示占位
- 新增/编辑弹窗在「模块路径」后加一行「模块图标」输入框(clearable,非必填)
- 回显、新增/子模块初始值、两处提交参数均已带上 icon;空串归一为不下发该字段,
  后端 edit 逐字段整体赋值,故清空图标可生效
2026-09-15 18:11:18 +08:00
chenxing 6750a735c2 fix(auth): 改用 zTree 原生级联替代手动 checkNode,彻底修复级联错乱
build / build (push) Successful in 1m58s
手动代码级联与 zTree 内部状态机(checkNode 提前 return、setChkClass、repairParentChkClassWithSelf)反复交互,
导致除 open 外级联视觉完全失效、点击父节点无法联动子孙,整体错乱。

改为 zTree 原生 chkboxType {Y:"ps", N:"s"}:
- Y="ps":勾选任意节点 -> 父/祖先全部勾选 + 子孙全部勾选
- N="s":取消节点 -> 子孙全部取消,父/祖先不受影响
恰好对应需求 4 条规则(勾选子->祖先勾选;勾选父->子孙勾选;取消父->子孙取消;取消子->父/祖不动),
且视觉由 zTree 原生刷新,不再依赖手动 checkNode。

onTreeCheck 退化为只做本地数据同步:原生级联完成后把应用子树最终勾选态
(syncAppModulesFromTree)写回 AppItem.modules 作为提交依据;GroupAuth 仍按模块明细 diff,
角色授权页仍全量防抖保存。
2026-09-15 17:45:43 +08:00
chenxing af367ddd19 fix(auth): 修复勾选级联视觉不生效——checkNode 提前 return 跳过外观刷新
build / build (push) Successful in 1m58s
zTree checkNode 在 node.checked 已等于目标值且 checkTypeFlag=false 时会提前 return,
setChkClass(复选框外观刷新)被跳过。原先实现先手动赋值 n.checked 再调 checkNode,
导致除点击节点外,联动到的祖先/子孙节点只改数据、外观不刷新,表现为级联未生效。
改为由 checkNode 内部设置 checked 并刷新外观后再处理后续联动。
2026-09-15 17:18:21 +08:00
chenxing e1cdc26697 feat(auth): 树勾选级联+点击文字勾选+树默认闭合
build / build (push) Successful in 3m19s
- 新增共享级联工具 utils/treeCascade.ts:勾选子->祖先全部勾选;勾选父->子孙全部勾选/取消;取消子->父/祖不受影响(zTree 侧 chkboxType 置空,由代码级联)
- GroupAuth.vue:接入代码级联,模块明细按 diff 提交(追加/删除),取消时同步清理子组继承标记
- AppModuleCheckTree.vue:角色授权接入同一套级联逻辑
- 组/角色授权树与应用节点默认闭合,点击文字等价勾选框(MyTree onClick 同步触发 onCheck)
- PermissionView.vue「已授权应用」应用一级默认不展开模块
2026-09-15 16:51:43 +08:00
chenxing e0252eb997 fix(user): 编辑用户「所在组」路径去掉分隔符两侧空格
build / build (push) Successful in 1m51s
groupOptions 拍平组织树时完整路径分隔符由 " / " 改为 "/",
使所在组显示为 教育云中心/信阳教育局/…/202601。
2026-09-11 10:14:22 +08:00
admin df5f5d2cff ci: update build workflow [skip ci] 2026-09-10 19:43:54 +08:00
admin 659a569054 init: readme guide [skip ci] 2026-09-10 19:43:49 +08:00
chenxing c1e4603fa4 ci修订:恢复构建容器名唯一化(模板重注入回退修复)
build / build (push) Failing after 2m2s
远端 4bf8efe 删除整个 .gitea/workflows 目录后,194e761 由 projman 模板重新注入
build.yml,而 projman 的 templates/ci/frontend.yml 仍是固定容器名 frontbuild,
导致 34c2496 的容器名唯一化修复被回退。

runner 挂的是宿主 docker.sock,容器名在整个 daemon 上是共享命名空间,固定名
frontbuild 会在多仓/多条 workflow 并发构建时互相 docker rm -f 掉对方正在构建的
容器,表现为无编译报错的 exit 137(SIGKILL)。

本次仅恢复 build.yml 的容器名为 BUILD_CONTAINER=frontbuild-${GITHUB_RUN_ID:-0}-$(date +%s),
不引入 deploy.yml(远端已删除,部署通道统一走 build.yml -> Nexus -> projman 前端发布)。
2026-09-10 19:32:49 +08:00
admin 194e76184a ci: init build workflow [skip ci] 2026-09-10 15:01:43 +08:00
chenxing 4bf8efedbb 删除目录「.gitea/workflows」 2026-09-10 15:01:20 +08:00
admin 12f9771ed5 ci: update build workflow [skip ci] 2026-09-10 14:58:09 +08:00
chenxing 3697121c03 移除未使用的 sass-loader 并重生 lock(修 build.yml 在 CI 里 npm ci 失败)-本项目是 vite 工程,src 与 vite.config.ts 对 sass-loader 零引用,scss 编译由 dependencies 里的 sass(dart-sass)承担;但 sass-loader@10 作为 peer 把 node-sass@6 拖进了 package-lock,CI 的 npm ci 在 node:20 容器里必须装 node-sass(最高只支持 Node 16,无预编译二进制,postinstall 退回 node-gyp 源码编译且内网拉不到 nodejs.org 头文件),故 build.yml 自加入起两次 run 全挂;本地 npm ci --dry-run 实测确认安装集含 node-sass 6.0.1,清理后 node-sass 与 webpack 子树引用均为 0;lock 同步移除 sass-loader 连带拖入的整条 webpack 工具链(@webassemblyjs/*、terser-webpack-plugin、schema-utils、neo-async 等,3005 行删除),node_modules 未动(--package-lock-only),npm run build 本地通过;做法与 Notice d2d0eb6 一致
build / build (push) Failing after 4m1s
2026-09-10 12:36:38 +08:00
chenxing 34c2496704 ci修订:构建容器名唯一化+deploy.yml改手动触发(修复 exit 137 互杀)-根因是 runner 挂宿主 docker.sock,容器名在整个 daemon 上是共享命名空间,而 build.yml 与新加的 deploy.yml 都写死固定名 frontbuild,两条又都挂 push 触发:同一次 push 并发起两个 job,各自的 docker rm -f frontbuild 会把对方正在 npm ci/build 的容器直接 SIGKILL 掉,表现为无编译报错的 exitcode 137(2026-09-10 三仓同推即撞,UserBase 两条 run 与 Cms run 全红、Notice 侥幸绿);改法:两条 workflow 内 BUILD_CONTAINER=frontbuild-${GITHUB_RUN_ID}-${时间戳} 全步引用(run/exec/cp/rm 统一),GITHUB_RUN_ID 实例级唯一、时间戳兜底手动重跑与未注入场景;部署通道收敛:push 自动部署归一 build.yml→Nexus→projman 前端发布,deploy.yml 的 on 去掉 push 只留 workflow_dispatch(需要直连宿主 /data/www/userbase 软链秒级回滚时在 Actions 手动 Run),避免同一次提交被部署两遍;四条 workflow YAML 解析通过、改动 step sh -n 语法通过
build / build (push) Failing after 3m10s
2026-09-10 12:16:31 +08:00
chenxing efff0eba39 退出登录图标接入 ysch 单 svg+首页入口与登出路由修订:从 YschIcons/svg 复制 logout.svg、shouye.svg 进 src/assets/icons(雪碧图根目录文件即 #icon-{name}),退出与头部按钮改用现成 SvgIcon 渲染,不再依赖 index.html 引入的 bschload.min.js 平台 iconfont 字体(与 Notice 同形同尺寸:退出 18px、头部 22px,均白色);<svg> 默认 inline 带基线降部空隙,新增组件 scoped 补 display:block 与首页按钮 flex 居中(layout.css 的 .logout i/.icon-btn i 只命中 <i>,不改压缩产物);头部「常用应用」改「返回首页」(本项目 appList 实为 store.modules,menu.ts 无 url/icon 键,面板渲染 href="undefined" 属坏入口;面板 DOM 与 showPanel/isPanel 保留仅失去入口);linkHomePage 仅改独立 Web 分支 URL /BschHome→/userhome/front/index.html(线上 BschHome 实测 404),嵌入态 BschLoadUtil.goBackDesktop() 逻辑原样保留;登出路由 /cas/logout→/logout 与 Notice/UserhomeFront 统一(/cas/logout 在 SSO 侧无效);build 通过
build / build (push) Failing after 5s
deploy-userbase-front / build-and-deploy (push) Failing after 11s
2026-09-10 11:58:26 +08:00
admin 47a5184ddc ci: init build workflow [skip ci] 2026-09-09 18:01:38 +08:00
69 changed files with 5968 additions and 3993 deletions
+8
View File
@@ -1,9 +1,17 @@
# 开发环境 (dev):仅 vite dev 加载(--mode dev)。值可自由指本地或线上地址。 # 开发环境 (dev):仅 vite dev 加载(--mode dev)。值可自由指本地或线上地址。
# build 产物不读此文件(build 默认 production mode,只认 .env / .env.production,两者均不存在), # build 产物不读此文件(build 默认 production mode,只认 .env / .env.production,两者均不存在),
# 线上环境差异全部由 projman 的 env-config-{目标}.json 承担。 # 线上环境差异全部由 projman 的 env-config-{目标}.json 承担。
#
# 本地起服务的三种模式(详见 vite.config.ts 的 server.proxy 注释):
# 纯前端 mock: VITE_IS_MOCK=true npm run dev → 用户/菜单走 src/assets/mock,其余接口透传给后端
# 本地后端联调: npm run dev → 跨源直连本文件 VITE_SERVICE_HOST
# 线上后端联调: VITE_SERVICE_HOST= YH_API_PROXY_TARGET=http://base.yunhaixingtong.com/userbase npm run dev
# (留空 VITE_SERVICE_HOST 让请求走同源 /api,由 dev 代理转发;CAS 登录 cookie 跨域带不过去时改用本地后端)
VITE_BASE_HOST=http://base.bestsch.com VITE_BASE_HOST=http://base.bestsch.com
VITE_SSO_HOST=http://sso.yunhaixingtong.com VITE_SSO_HOST=http://sso.yunhaixingtong.com
VITE_SERVICE_HOST=http://localhost:8060 VITE_SERVICE_HOST=http://localhost:8060
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
VITE_IS_MOCK=false VITE_IS_MOCK=false
# 主题通道 dev 跨源直连(可选,需通道已开 CORS);留空则同源相对 → 走 vite 的 /static 代理
VITE_THEME_HOST=
+114
View File
@@ -0,0 +1,114 @@
name: build
# 前端应用 CI:构建 → 打包 zip → 推 Nexus raw → 通知 projman 建档
# 触发:push 到 main / 手动 Run workflow
# 链路:clone 源码 -> node 容器 npm ci + npm run build -> 打包 dist 为 zip -> 推 Nexus raw -> 通知 projman 建档
# 依赖仓库 Secrets:DEPLOY_TOKEN(webhook 鉴权)、NEXUS_USER / NEXUS_PASSWORD(上传 zip 到 Nexus raw)
# 注意:CI 构建后自动建档(status=pending),前端发布仍由用户在「前端发布」Tab 点部署触发
on:
push:
branches: [ main ]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
container:
image: docker:24
options: --network infra-infra-net
steps:
- name: 安装 git / curl / zip(alpine)
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 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git clone --depth 1 --branch main "http://x-access-token:${GITHUB_TOKEN}@gitea.yunhaixingtong.com/${{ github.repository }}.git" /tmp/src
cp -a /tmp/src/. .
rm -rf /tmp/src
# 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符
COMMIT_MSG=$(git log -1 --pretty=%s | tr '\n' ' ' | sed 's/["\\]/ /g' | cut -c1-200)
echo "COMMIT_MSG=${COMMIT_MSG}" >> "$GITHUB_ENV"
# 前端构建:起独立 node 容器(通过 docker.sock 调宿主 dockerd)
# node:20 镜像预装 npm,npm ci 按 lockfile 安装依赖
- name: 构建前端(node 容器)
run: |
set -e
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
# 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
cd /build
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 run build
'
echo "=== dist 产物 ==="
docker exec "$BUILD_CONTAINER" sh -c 'ls -la /build/dist/ | head -20'
# 从容器 cp dist 到宿主工作目录
docker cp "$BUILD_CONTAINER":/build/dist ./dist
docker rm -f "$BUILD_CONTAINER"
echo "BUILD CHECK OK"
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
- name: 打包产物
run: |
TAG=$(echo "$GITHUB_SHA" | cut -c1-8)
ZIP_FILE="userbase-front-${TAG}.zip"
cd dist && zip -r "../${ZIP_FILE}" . && cd ..
echo "ZIP_FILE=${ZIP_FILE}" >> "$GITHUB_ENV"
echo "TAG=${TAG}" >> "$GITHUB_ENV"
ls -la "${ZIP_FILE}"
# 上传到 Nexus raw 仓库
- name: 上传产物到 Nexus
env:
NEXUS_USER: ${{ secrets.NEXUS_USER }}
NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }}
run: |
set -e
NEXUS_URL="http://nexus.yunhaixingtong.com:8081/repository/front-hosted/${ZIP_FILE}"
# -f:HTTP >=400 时 curl 以非零退出,仓库不存在/认证失败必须让 job 变红,否则建档的是不存在的 URL
curl -sSf -X PUT \
-u "${NEXUS_USER}:${NEXUS_PASSWORD}" \
--upload-file "${ZIP_FILE}" \
"${NEXUS_URL}"
echo "NEXUS_URL=${NEXUS_URL}" >> "$GITHUB_ENV"
echo "uploaded to ${NEXUS_URL}"
# 通知 projman:webhook 收到后建档(type=front, status=pending),部署由用户在「前端发布」Tab 手动触发
- name: 通知 projman 建档
run: |
set -e
curl -sSf -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \
-H "Content-Type: application/json" \
-H "X-Deploy-Token: ${{ secrets.DEPLOY_TOKEN }}" \
-d "{\"app\":\"userbase\",\"front\":\"front\",\"tag\":\"${TAG}\",\"sha\":\"${GITHUB_SHA}\",\"message\":\"${COMMIT_MSG}\",\"nexusUrl\":\"${NEXUS_URL}\"}"
-88
View File
@@ -1,88 +0,0 @@
name: deploy-userbase-front
# 前端部署(方案 B:不打包镜像,直接部署静态文件 + 软链接版本控制)
# 触发:push 到 main(已启用仓库 Actions,push 自动部署)/ 手动 Run workflow
# 核心:vite build --mode prod -> dist/prod
# -> 拷贝到 /data/www/userbase/releases/<version>/
# -> 原子切换 /data/www/userbase/front 软链接指向新版本
# -> 保留最近 5 个版本,更早自动清理(秒级回滚)
# 不再构建/推送 nginx 镜像;Caddy 已改为 file_server(root=/srv/userbase=/data/www/userbase)。
# 依赖:160 宿主的 docker daemon(runner 已挂 docker.sock),且 runner valid_volumes 已放行 /data/www/**。无需 NEXUS secret。
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
# docker:24 自带 docker CLI,复用宿主 docker daemon(act_runner v0.6.1 已挂载 docker.sock)。
# 切勿再声明 volumes: docker.sock(会报 Duplicate mount point)。
container:
image: docker:24
steps:
- name: Install git (alpine)
run: apk add --no-cache git
# 内网 Gitea 无法访问 github.com 拉取 actions/checkout,改为直接 clone 本仓库
- name: Checkout (manual, internal Gitea)
run: |
git clone --depth 1 --branch main http://192.168.10.160:3000/${GITHUB_REPOSITORY}.git /tmp/src
cp -a /tmp/src/. .
rm -rf /tmp/src
# 构建 + 部署:在 node:20 容器里完成(源码经 docker cp 传入,并挂载宿主 /data/www/userbase)
- name: Build & Deploy (symlink versioning)
run: |
set -e
# 版本号:short sha + 时间戳(可改为 git tag 优先,见下)
VERSION="$(echo ${GITHUB_SHA} | cut -c1-7)-$(date +%Y%m%d-%H%M)"
echo "Deploying version: $VERSION"
docker rm -f frontbuild 2>/dev/null || true
docker run -d --name frontbuild \
-e VERSION="$VERSION" \
-v /data/www/userbase:/data/www/userbase \
-w /build node:20 sleep 3600
# 源码拷入构建容器(绕过 act 工作区非宿主路径的坑,与 service 的 mavenbuild 一致)
docker cp "${PWD}/." frontbuild:/build/
docker exec frontbuild sh -c '
set -e
cd /build
# 去掉 Windows 专用 sass 二进制路径(Linux 容器内该路径无效,否则 build 报错)
# .npmrc 被 gitignore、CI 克隆下来没有它,故仅在存在时才删(缺失不报错)
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
npm ci
npm run build:prod
'
# 部署:首次把真实目录 front 迁入 releases/initial 并建软链;之后均为软链替换
docker exec frontbuild sh -c '
set -e
cd /build
# ---- 首次初始化:front 当前是真实目录,无法直接 ln -sfn 覆盖 ----
if [ ! -L /data/www/userbase/front ]; then
mkdir -p /data/www/userbase/releases
mv /data/www/userbase/front /data/www/userbase/releases/initial
# 相对软链:Caddy 容器内 /data/www/userbase 挂在 /srv/userbase,
# 绝对路径在容器内解析不到,必须用相对路径(相对 front 所在目录)
ln -sfn releases/initial /data/www/userbase/front
echo "INIT: front -> releases/initial (保留上线前状态用于回滚)"
fi
mkdir -p /data/www/userbase/releases/$VERSION
cp -r /build/dist/prod/. /data/www/userbase/releases/$VERSION/
# 原子切换:front 此时已是软链接,ln -sfn 直接替换指向(必须用相对路径)
ln -sfn releases/$VERSION /data/www/userbase/front
echo "current symlink ->"; ls -la /data/www/userbase/front
# 清理旧版本,保留最近 5 个(initial 也会被计入,最多保留 5 个含 initial)
cd /data/www/userbase/releases
ls -1t | tail -n +6 | while read d; do echo "rm old: $d"; rm -rf "$d"; done
echo "remaining versions:"; ls -1t
'
docker rm -f frontbuild
echo "DEPLOY OK: $VERSION"
+53
View File
@@ -0,0 +1,53 @@
# UserbaseFront
> 本仓库由 projman 自动创建并注入 CI(`.gitea/workflows/build.yml`)与 Dockerfile(`docker/Dockerfile`)。
> 推送代码到 main 分支即自动构建镜像并推送到 Nexus,projman 收到通知后建档待部署,由运维在 projman「后端发布」手动点部署。
## 首次推送本地代码到本仓库
> 目标:把本地已有代码推上去,同时**保留** projman 注入的文件(`build.yml`、`Dockerfile`)。
```bash
# 1. 进入本地代码目录
cd /your/local/code/dir
# 2. 初始化 git(目录已有 .git 也安全,git init 幂等无副作用)
git init
git branch -M main
# 3. 先把本地代码提交成一个 commit(关键!不提交直接 merge 会丢失本地代码)
git add -A
git commit -m "init local code"
# 4. 关联远程仓库(幂等:已存在 origin 则改地址,不报错)
git remote add origin http://gitea.yunhaixingtong.com/userbase/UserbaseFront.git 2>/dev/null \
|| git remote set-url origin http://gitea.yunhaixingtong.com/userbase/UserbaseFront.git
# 5. 拉取远程信息(只下载,不合并)
git fetch origin
# 6. 合并远程 main(关键步骤)
# 本地与远程是两条独立历史,需允许合并不相关历史。
# 此步会把 projman 注入的 build.yml 与 Dockerfile 合并进你的本地工作区。
git merge origin/main --allow-unrelated-histories -m "merge projman injected files"
# 7. 查看状态(确认 .gitea/workflows/build.yml、docker/Dockerfile 已在本地)
git status
# 8. 提交并推送
git add -A
git commit -m "sync local code" 2>/dev/null || true
git push origin main
```
## 推送后验证
push 触发 CI 构建,前往 Gitea 仓库 → **Actions** 查看日志;构建成功后镜像推送到 Nexus,projman 建档待部署,在 projman 应用详情「后端发布」手动点部署。
## 禁止写法(会导致注入文件被删)
`git reset origin/main` + `git add -A` 会把远程已注入、但你本地没有的文件(`build.yml`、`Dockerfile`)当作"删除"提交并推送,导致这些文件被删、CI 失败。务必用 `git merge` 而非 `git reset`,且 merge 前先提交本地代码。
## 关于 Dockerfile
projman 注入的 Dockerfile 仅在仓库不存在时写入;你自备的 Dockerfile 优先,不会被覆盖。
+20
View File
@@ -27,6 +27,26 @@
} }
})(); })();
</script> </script>
<!-- 主题通道引导(A 类免插件接入,见 YschTheme《管理端主题远程化与公共静态通道方案》§11):
唯一接入点。不装 npm 依赖、不改构建配置;FOUC 门、manifest 预取、主题 <link> 追加与
排序(晚于 viewuiplus.css)全由通道 yh-theme-init.js 承担,主题升级走 /static 通道,
本应用无需重新构建。
origin 零写死,优先级四档:__APP_CONFIG__.staticHost(专用通道覆盖)
> __APP_CONFIG__.baseHost(env-config 在线维护,换域名改配置即生效)
> %VITE_THEME_HOST%(仅 dev 注入的跨源直连,需通道已开 CORS)
> 同源相对 "/static/…"(生产常态:本应用部署在 base 域 /userbase/front/,与通道同源;
本地 dev 由 vite.config 的 /static 代理兜到通道)。
document.write 是有意为之:保证 parser-blocking,让档位类与 FOUC 门先于首帧;
build 不读 .env,占位符留字面量 → 判首字符 % 置空 → 产物零环境值烧入。 -->
<script>
(function () {
var c = window.__APP_CONFIG__ || {};
var themeHost = "%VITE_THEME_HOST%";
if (themeHost.charAt(0) === "%") themeHost = "";
var host = String(c.staticHost || c.baseHost || themeHost).replace(/\/+$/, "");
document.write('<script src="' + host + '/static/js/yh-theme-init.js"><\/script>');
})();
</script>
<title><%- title %></title> <title><%- title %></title>
<link rel="stylesheet" href="./libs/ztree/zTreeStyle.css" /> <link rel="stylesheet" href="./libs/ztree/zTreeStyle.css" />
<script src="./libs/jquery.min.js"></script> <script src="./libs/jquery.min.js"></script>
+4 -2988
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -22,7 +22,7 @@
"mockjs": "^1.1.0", "mockjs": "^1.1.0",
"pinia": "^2.1.4", "pinia": "^2.1.4",
"sass": "^1.64.1", "sass": "^1.64.1",
"view-ui-plus": "^1.3.16", "view-ui-plus": "^1.3.24",
"vite-plugin-mock": "2.9.6", "vite-plugin-mock": "2.9.6",
"vue": "^3.4.19", "vue": "^3.4.19",
"vue-router": "4", "vue-router": "4",
@@ -35,7 +35,6 @@
"@unocss/preset-uno": "^66.6.0", "@unocss/preset-uno": "^66.6.0",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"sass-loader": "^10.0.1",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"unocss": "^66.6.0", "unocss": "^66.6.0",
"unplugin-auto-import": "0.18.3", "unplugin-auto-import": "0.18.3",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

+1 -1
View File
@@ -70,7 +70,7 @@ const setCurrentRole = (user: any) => {
}; };
localStorage.setItem("currentRole", encodeURI(JSON.stringify(currentRole))); localStorage.setItem("currentRole", encodeURI(JSON.stringify(currentRole)));
// 主题样式已本地化到 src/assets/styles/layout.css(含 default-theme / base.layout.beta / _iview_override),不再请求远程样式 // 主题样式由中央静态通道下发(index.html 的 yh-theme-init.js 挂 /static/*),本仓不再持有 layout.css
if (user?.type === 1) { if (user?.type === 1) {
store.setUserRole(UserRoleEnum.SuperAdmin); store.setUserRole(UserRoleEnum.SuperAdmin);
+21
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'],
@@ -85,6 +105,7 @@ export default {
'SysOapiLog.oapiLogDownload': ['GET', '/api/oapilog/Download/{id}'], 'SysOapiLog.oapiLogDownload': ['GET', '/api/oapilog/Download/{id}'],
'GroupAuthz.userRoles': ['GET', '/api/groupauthz/UserRoles'], 'GroupAuthz.userRoles': ['GET', '/api/groupauthz/UserRoles'],
'GroupAuthz.userPermissionTree': ['GET', '/api/groupauthz/UserPermissionTree'], 'GroupAuthz.userPermissionTree': ['GET', '/api/groupauthz/UserPermissionTree'],
'GroupAuthz.groupPermissionTree': ['GET', '/api/groupauthz/GroupPermissionTree'],
'GroupAuthz.userGroupTree': ['GET', '/api/groupauthz/UserGroupTree'], 'GroupAuthz.userGroupTree': ['GET', '/api/groupauthz/UserGroupTree'],
'GroupAuthz.searchUsers': ['GET', '/api/groupauthz/SearchUsers'], 'GroupAuthz.searchUsers': ['GET', '/api/groupauthz/SearchUsers'],
'GroupAuthz.hasModulePerm': ['GET', '/api/groupauthz/HasModulePerm'], 'GroupAuthz.hasModulePerm': ['GET', '/api/groupauthz/HasModulePerm'],
+651 -3
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;
@@ -179,11 +232,18 @@ export interface GroupAppAssignRequest {
} }
export interface UserAppRoleBindRequest { export interface UserAppRoleBindRequest {
userId: number; userId: number;
/** 绑定组ID(可选):角色归属的组,空则后端兜底主组 */
groupId?: number;
roleIds?: number[]; roleIds?: number[];
} }
export interface AppRoleUserBinding {
userId: number;
/** 绑定发生时所选的组ID */
groupId: number;
}
export interface AppRoleUserBindRequest { export interface AppRoleUserBindRequest {
roleId: number; roleId: number;
userIds?: number[]; bindings?: AppRoleUserBinding[];
} }
export interface AppRoleModuleAssignRequest { export interface AppRoleModuleAssignRequest {
roleId: number; roleId: number;
@@ -216,6 +276,8 @@ export interface ApplicationInfoRequest {
name: string; name: string;
categoryId: number; categoryId: number;
desc?: string; desc?: string;
appCode?: string;
appNature?: string;
clients?: number[]; clients?: number[];
pcUrl?: string; pcUrl?: string;
pcIcon?: string; pcIcon?: string;
@@ -223,6 +285,7 @@ export interface ApplicationInfoRequest {
appIcon?: string; appIcon?: string;
officialAccountUrl?: string; officialAccountUrl?: string;
dingtalkUrl?: string; dingtalkUrl?: string;
miniprogramUrl?: string;
} }
export interface SysUserMoveRequest { export interface SysUserMoveRequest {
userId: number; userId: number;
@@ -234,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 {
@@ -304,12 +375,14 @@ export interface SysUserVo {
email?: string; email?: string;
gender?: number; gender?: number;
profilePicture?: string; profilePicture?: string;
roles?: number[];
disabled?: number; disabled?: number;
type?: number; type?: number;
sortNo?: number; sortNo?: number;
groupStatus?: number; groupStatus?: number;
/** 主校身份名(如「老师/校领导」):列表「用户身份」列展示字段(roles 列已停写) */ /**
* 用户身份名(如「老师、校领导」):列表「用户身份」列展示字段。
* 组上下文列表只含「当前组范围解析出的学校」下的身份(与编辑弹窗「所在组」同源);未选组才全量。
*/
identityNames?: string; identityNames?: string;
expired?: boolean; expired?: boolean;
createTime?: string; createTime?: string;
@@ -677,6 +750,10 @@ export interface AppRoleUserVo {
id?: number; id?: number;
account?: string; account?: string;
realName?: string; realName?: string;
/** 绑定发生时所选的组ID */
groupId?: number;
/** 组路径(查询时按 groupId 现算,scope 裁剪后) */
groupPath?: string;
} }
export interface AppRoleAuthzModuleVo { export interface AppRoleAuthzModuleVo {
id?: number; id?: number;
@@ -753,6 +830,8 @@ export interface ApplicationInfoVo {
desc?: string; desc?: string;
appKey?: number; appKey?: number;
appSecret?: string; appSecret?: string;
appCode?: string;
appNature?: string;
clients?: number[]; clients?: number[];
pcUrl?: string; pcUrl?: string;
pcIcon?: string; pcIcon?: string;
@@ -760,6 +839,7 @@ export interface ApplicationInfoVo {
appIcon?: string; appIcon?: string;
officialAccountUrl?: string; officialAccountUrl?: string;
dingtalkUrl?: string; dingtalkUrl?: string;
miniprogramUrl?: string;
} }
export interface BasePageResultApplicationInfoVo { export interface BasePageResultApplicationInfoVo {
total?: number; total?: number;
@@ -1519,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>;
/** /**
* --- * ---
* *
@@ -1686,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: {
/** /**
* --- * ---
@@ -2031,6 +2673,8 @@ declare global {
Config extends Alova2MethodConfig<UserRoleVo[]> & { Config extends Alova2MethodConfig<UserRoleVo[]> & {
params: { params: {
userId: number; userId: number;
/** 组ID(可选):传入时只返回该组下的绑定(编辑弹窗按所在组回显) */
groupId?: number;
}; };
} }
>( >(
@@ -2129,6 +2773,8 @@ declare global {
Config extends Alova2MethodConfig<UserGroupNodeVo[]> & { Config extends Alova2MethodConfig<UserGroupNodeVo[]> & {
params: { params: {
userId: number; userId: number;
/** 上下文组ID(可选):传入且用户确属该组时只画该组祖先链 */
groupId?: number;
}; };
} }
>( >(
@@ -3036,6 +3682,8 @@ declare global {
params: { params: {
roleId: number; roleId: number;
userId: number; userId: number;
/** 绑定组ID:按行精确删除 */
groupId: number;
}; };
} }
>( >(
+23
View File
@@ -0,0 +1,23 @@
{
"prefix": "appicon",
"info": {
"name": "AppIcon",
"total": 2,
"author": "ysch front-end team",
"license": {
"title": "Remix Icon (Apache-2.0) 等,见 meta.json source",
"spdx": "Apache-2.0"
},
"height": 1024
},
"icons": {
"tongzhi": {
"body": "<path d=\"M853.33 725.33H938.67V810.67H85.33V725.33H170.67V426.67C170.67 238.15 323.49 85.33 512 85.33C700.51 85.33 853.33 238.15 853.33 426.67V725.33ZM768 725.33V426.67C768 285.28 653.38 170.67 512 170.67C370.62 170.67 256 285.28 256 426.67V725.33H768ZM384 896H640V981.33H384V896Z\" ></path>",
"cn": "通知"
},
"wenjuan": {
"body": "<path d=\"M725.33 85.33V170.67H853.61C877.02 170.67 896 189.65 896 213.05V896.28C896 919.69 877.02 938.67 853.61 938.67H170.39C146.98 938.67 128 919.68 128 896.28V213.05C128 189.64 146.98 170.67 170.39 170.67H298.67V85.33H725.33ZM298.67 256H213.33V853.33H810.67V256H725.33V341.33H298.67V256ZM384 682.67V768H298.67V682.67H384ZM384 554.67V640H298.67V554.67H384ZM384 426.67V512H298.67V426.67H384ZM640 170.67H384V256H640V170.67Z\" ></path>",
"cn": "问卷"
}
}
}
+15 -1
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 自动推导,用于「高亮自身 + 面包屑展示父级」联动。
@@ -62,7 +65,18 @@ export function getMenuList(userRole?: UserRoleEnum): Promise<IModule[]> {
{ id: 53, modId: 1111, name: "操作日志", page: "/oplog", roles: [UserRoleEnum.SuperAdmin] }, { id: 53, modId: 1111, name: "操作日志", page: "/oplog", roles: [UserRoleEnum.SuperAdmin] },
{ id: 56, modId: 1114, name: "开放接口日志", page: "/oapilog", roles: [UserRoleEnum.SuperAdmin] }, { id: 56, modId: 1114, name: "开放接口日志", page: "/oapilog", roles: [UserRoleEnum.SuperAdmin] },
{ id: 54, modId: 1112, name: "推送日志", page: "/push-log", roles: [UserRoleEnum.SuperAdmin] }, { id: 54, modId: 1112, name: "推送日志", page: "/push-log", roles: [UserRoleEnum.SuperAdmin] },
{ id: 55, modId: 1113, name: "测试推送", page: "/push-test", 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] },
], ],
}, },
]; ];
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 1024 1024" width="1em" height="1em"><path transform="translate(-56.6 -56.8) scale(1.11111)" d="M555 427Q555 444 542.5 456.5Q530 469 512.0 469.0Q494 469 481.5 456.5Q469 444 469 427V128Q469 117 475.0 107.0Q481 97 491.0 91.0Q501 85 512.0 85.0Q523 85 533.0 91.0Q543 97 549.0 107.0Q555 117 555 128ZM657 293Q646 287 640.0 275.5Q634 264 635.5 251.5Q637 239 645.5 229.0Q654 219 666 216Q683 211 698 219Q783 266 835.0 347.5Q887 429 894.5 525.5Q902 622 863.0 710.5Q824 799 747 858Q679 911 595.5 929.5Q512 948 428.5 929.5Q345 911 276.5 858.0Q208 805 169.5 729.0Q131 653 128.0 567.5Q125 482 159 403Q184 344 227.0 297.0Q270 250 326 219Q341 211 357.5 216.0Q374 221 382.5 236.0Q391 251 386.5 267.5Q382 284 367 293Q308 326 269.5 380.0Q231 434 218.5 499.0Q206 564 223.0 629.0Q240 694 282.5 745.0Q325 796 385.0 824.5Q445 853 512.0 853.0Q579 853 639.0 824.5Q699 796 741.5 745.0Q784 694 801.0 629.0Q818 564 805.5 499.0Q793 434 754.5 380.0Q716 326 657 293Z"/></svg>

After

Width:  |  Height:  |  Size: 1017 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 1024 1024" width="1em" height="1em"><path d="M802.5 946H603.8C575 945.9 551.7 922.6 551.6 893.8V694.2H472.8V893.8C472.8 922.5 449.3 946 420.6 946H219.1C190.4 945.9 167.1 922.6 167.1 893.8V566.4H77.8C55.9 566.6 36.3 553 28.8 532.4C21.2 512.1 27 489.2 43.4 475L468.2 95.9L468.5 95.7C496.5 71.5 538.3 72.3 565.4 97.5L981.9 476.2L982 476.5C997.7 491.2 1002.8 513.7 994.7 533.6C986.9 553.5 967.7 566.6 946.3 566.5L854.6 566.5V893.8C854.6 922.5 831.2 946 802.5 946ZM615.2 882.4H791V503H916.7L522.3 144.3L522.2 144.2C518.9 141.1 513.8 140.9 510.3 143.7L107.8 502.8H230.6V882.4H409.2V682.8C409.2 654.2 432.6 630.8 461.4 630.8H563C591.7 630.8 615.2 654.2 615.2 682.8V882.4L615.2 882.4Z" ></path></svg>

After

Width:  |  Height:  |  Size: 766 B

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

File diff suppressed because one or more lines are too long
+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;
}
+315
View File
@@ -0,0 +1,315 @@
<template>
<div class="appicon-picker">
<!-- 当前选中态 / 清除 -->
<div class="picker-head">
<div class="picked" v-if="modelValue">
<span class="picked-glyph scene-tile" v-html="svgOf(modelValue)"></span>
<span class="picked-info">
<span class="picked-cn">{{ cnOf(modelValue) }}</span>
<span class="picked-key">{{ modelValue }}</span>
</span>
<Icon type="ios-close" class="picked-clear" title="清除(回到位图/默认回退链)" @click.stop="clearPick" />
</div>
<Button v-else size="small" icon="md-add" @click="open = true">选择矢量图标</Button>
<Button v-if="modelValue" size="small" class="ml-8" @click="open = true">更换</Button>
</div>
<!-- 着色:不指定 = 端侧默认(PC 白色 / App 分组色) -->
<div class="color-row" v-if="modelValue">
<span class="color-label">着色:</span>
<Tooltip content="空 = 跟随端侧场景默认(PC 白图标、App 分组色),一般无需指定" placement="top" transfer>
<span class="help-icon">?</span>
</Tooltip>
<div class="swatches">
<div class="swatch swatch-none" :class="{ active: !color }" title="端侧默认" @click="pickColor('')">无</div>
<div v-for="c in PRESET_COLORS" :key="c" class="swatch" :class="{ active: color === c }"
:style="{ background: c }" :title="c" @click="pickColor(c)"></div>
</div>
</div>
<Modal v-model="open" title="选择矢量图标(AppIcon 资产库)" width="640" footer-hide transfer :mask-closable="true">
<div class="picker-body">
<Input v-model="keyword" prefix="ios-search" placeholder="搜索中文名或图标名" clearable />
<div class="picker-grid">
<div v-for="item in filtered" :key="item.key" class="picker-cell"
:class="{ active: item.key === modelValue }" @click="pick(item.key)">
<span class="scene-tile" v-html="svgOf(item.key)"></span>
<div class="cell-cn">{{ item.cn }}</div>
<div class="cell-key">{{ item.key }}</div>
</div>
<div class="picker-empty" v-if="!filtered.length">无匹配图标(清单未拉取成功时仅剩内置副本)</div>
</div>
<div class="picker-tip">
三场景同形:方块底(PC 应用卡)/ 圆底(App 首页)/ 线性(App 列表)——底色由各端场景决定,此处仅选形状。
</div>
</div>
</Modal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import builtinManifest from "@/assets/appicon-icons.json";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
/**
* 矢量图标选择器(AppIcon 资产库)。
* 清单来源:可配置 manifestUrl(默认线上 /static/appicon/icons.json),拉取失败回落本地内置副本
* (P1 静态应用未部署时管理端依然可用;副本与资产库 icons.json 同步,见资产库 build_dist)。
* 预览直接内联 svg body(不依赖字体加载),tile/circle/plain 三场景同形。
*/
const props = withDefaults(defineProps<{
/** 矢量图标键(AppIcon 图标名),空 = 不使用矢量图标 */
modelValue?: string;
/** 着色 CSS 色值,空 = 端侧默认 */
color?: string;
/** 清单地址(默认平台静态通道) */
manifestUrl?: string;
}>(), {
modelValue: '',
color: '',
});
const emit = defineEmits(['update:modelValue', 'update:color']);
const { $conf } = useCurrentInstance();
interface IconEntry { key: string; cn: string; body: string }
const open = ref(false);
const keyword = ref('');
const remoteIcons = ref<IconEntry[] | null>(null);
const PRESET_COLORS = ['#366ef4', '#3fa45b', '#f2994a', '#7c5cc4', '#17b8a6', '#ff5757'];
const builtinIcons: IconEntry[] = Object.entries((builtinManifest as any).icons || {}).map(
([key, v]: [string, any]) => ({ key, cn: v?.cn || key, body: v?.body || '' })
);
const icons = computed<IconEntry[]>(() => remoteIcons.value ?? builtinIcons);
const filtered = computed(() => {
const kw = keyword.value.trim().toLowerCase();
if (!kw) return icons.value;
return icons.value.filter(x => x.cn.includes(kw) || x.key.includes(kw));
});
function cnOf(key: string): string {
return icons.value.find(x => x.key === key)?.cn || key;
}
function bodyOf(key: string): string {
return icons.value.find(x => x.key === key)?.body || '';
}
function svgOf(key: string): string {
const body = bodyOf(key);
if (!body) return '';
return `<svg viewBox="0 0 1024 1024" fill="currentColor" xmlns="http://www.w3.org/2000/svg" style="width:1em;height:1em;display:block">${body}</svg>`;
}
function pick(key: string) {
emit('update:modelValue', key);
open.value = false;
}
function clearPick() {
emit('update:modelValue', '');
emit('update:color', '');
}
function pickColor(c: string) {
emit('update:color', c);
}
onMounted(async () => {
// 线上清单优先(P1 部署后自动生效);失败静默回落内置副本
try {
const url = props.manifestUrl || (($conf?.baseHost || '') + '/static/appicon/icons.json');
const res = await fetch(url, { credentials: 'omit' });
if (!res.ok) return;
const data = await res.json();
const list = Object.entries(data?.icons || {}).map(
([key, v]: [string, any]) => ({ key, cn: v?.cn || key, body: v?.body || '' })
);
if (list.length) remoteIcons.value = list;
} catch {
// 回落内置副本,不提示(本地开发 baseHost 指向本地时必然 404)
}
});
</script>
<style lang="scss" scoped>
.appicon-picker {
width: 100%;
}
.picker-head {
display: flex;
align-items: center;
gap: 8px;
}
.picked {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 10px 4px 4px;
border: 1px solid #dcdee2;
border-radius: 6px;
.picked-cn {
font-size: 13px;
color: #515a6e;
}
.picked-key {
font-size: 11px;
color: #999;
font-family: Consolas, monospace;
margin-left: 6px;
}
.picked-clear {
cursor: pointer;
color: #999;
font-size: 18px;
&:hover {
color: #ed4014;
}
}
}
.picked-info {
display: flex;
align-items: baseline;
}
.color-row {
display: flex;
align-items: center;
margin-top: 10px;
.color-label {
font-size: 13px;
color: #515a6e;
}
.help-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border: 1px solid #c5c8ce;
border-radius: 50%;
font-size: 11px;
color: #808695;
cursor: help;
margin-right: 8px;
}
}
.swatches {
display: flex;
gap: 8px;
}
.swatch {
width: 20px;
height: 20px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
box-sizing: border-box;
&.active {
border-color: #577afb;
box-shadow: 0 0 0 1px #577afb;
}
&.swatch-none {
background: #fff;
border: 1px dashed #c5c8ce;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: #808695;
&.active {
border-style: solid;
}
}
}
.picker-body {
.picker-grid {
margin-top: 12px;
max-height: 380px;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.picker-cell {
text-align: center;
padding: 10px 6px;
border: 1px solid #e8eaec;
border-radius: 6px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
&:hover {
border-color: #577afb;
}
&.active {
border-color: #577afb;
box-shadow: 0 0 0 1px #577afb;
}
.cell-cn {
font-size: 13px;
color: #515a6e;
margin-top: 8px;
}
.cell-key {
font-size: 11px;
color: #aaa;
font-family: Consolas, monospace;
}
}
.picker-empty {
grid-column: 1 / -1;
color: #bbb;
text-align: center;
padding: 24px 0;
}
.picker-tip {
margin-top: 12px;
font-size: 12px;
color: #808695;
line-height: 1.6;
}
}
/* 选中态/网格预览的方块场景(与端侧 tile token 同构,仅管理端演示用) */
.scene-tile {
--appicon-bg: #366ef4;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 18%;
background: var(--appicon-bg);
color: #fff;
font-size: 24px;
}
</style>
+12 -3
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,
@@ -285,10 +291,13 @@ defineExpose({
.tree-body { .tree-body {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: flex;
flex-direction: column;
} }
.tree-body :deep(.ztree) { /* 滚动职责统一由 MyTree(.my-tree > .ztree) 承担:此处只负责把可用高度传给子组件 */
height: 100%; .tree-body > .my-tree {
overflow-y: auto; flex: 1;
min-height: 0;
} }
</style> </style>
+36 -8
View File
@@ -1,26 +1,36 @@
<template> <template>
<Modal v-model="isModal" @on-visible-change="showChange" :title="title"> <!-- 确认框固定最高层:可能从任意 transfer 弹窗/抽屉(含手动抬层的 3000 抽屉)里唤起,
而 Modal 默认层级是全局计数器 1000+N,会被高 z-index 抽屉盖住 -->
<Modal v-model="isModal" @on-visible-change="showChange" :title="title" :z-index="4000" transfer>
<Alert type="warning" class="mb-1" show-icon> <Alert type="warning" class="mb-1" show-icon>
<template #icon> <template #icon>
<Icon type="ios-bulb-outline" :class="{ 'mt-5': !info }" slot="icon" :size="28"></Icon> <Icon type="ios-bulb-outline" :class="{ 'mt-5': !info }" :size="28"></Icon>
</template> </template>
<div v-if="msg" :class="['message-box', info ? '' : 'mt-10 mb-10 ml-10']">{{ msg }}</div> <div v-if="msg" :class="['message-box', info ? '' : 'mt-10 mb-10 ml-10']">{{ msg }}</div>
<template #desc v-if="info" slot="desc">{{ info }}</template> <template #desc v-if="info">{{ info }}</template>
</Alert> </Alert>
<div v-if="confirmName" class="confirm-name-block mb-1">
<div class="mb-5">
此操作<span style="font-weight: bold">无法</span>被回滚。<br />
请输入 <span class="confirm-name-text">{{ confirmName }}</span> 以确认:
</div>
<Input v-model="inputName" placeholder="请输入名称以确认" @on-enter="onOkClick" />
</div>
<template #footer> <template #footer>
<Button type="primary" :loading="okLoading" @click="onOkClick">{{ confirmText }}</Button> <Button :type="danger ? 'error' : 'primary'" :disabled="!nameMatched" :loading="okLoading" @click="onOkClick">{{ confirmText }}</Button>
<Button :loading="cancleLoading" @click="onCancleClick">{{ closeText }}</Button> <Button :loading="cancleLoading" @click="onCancleClick">{{ closeText }}</Button>
</template> </template>
</Modal> </Modal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, onMounted } from "vue"; import { ref, computed, onMounted } from "vue";
import { Modal, Button, Alert, Icon } from "view-ui-plus"; import { Modal, Button, Alert, Icon, Input } from "view-ui-plus";
const isModal = ref(false), const isModal = ref(false),
okLoading = ref(false), okLoading = ref(false),
cancleLoading = ref(false); cancleLoading = ref(false),
inputName = ref("");
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -30,6 +40,8 @@ const props = withDefaults(
isLoading?: boolean; isLoading?: boolean;
confirmText?: string; confirmText?: string;
closeText?: string; closeText?: string;
confirmName?: string;
danger?: boolean;
confirmFn: any; confirmFn: any;
closeFn: any; closeFn: any;
destroy: Function; destroy: Function;
@@ -39,9 +51,13 @@ const props = withDefaults(
isLoading: true, isLoading: true,
confirmText: "确认", confirmText: "确认",
closeText: "取消", closeText: "取消",
confirmName: "",
danger: false,
} }
); );
const nameMatched = computed(() => !props.confirmName || inputName.value.trim() === props.confirmName);
onMounted(function () { onMounted(function () {
setTimeout(() => { setTimeout(() => {
isModal.value = true; isModal.value = true;
@@ -49,6 +65,7 @@ onMounted(function () {
}); });
const onOkClick = async () => { const onOkClick = async () => {
if (!nameMatched.value) return;
if (!!props.isLoading) okLoading.value = true; if (!!props.isLoading) okLoading.value = true;
const { onOk, reslove } = props.confirmFn; const { onOk, reslove } = props.confirmFn;
await Promise.resolve(onOk()); await Promise.resolve(onOk());
@@ -73,4 +90,15 @@ const showChange = (state: boolean) => {
}; };
</script> </script>
<style></style> <style lang="scss" scoped>
.confirm-name-block {
padding: 0 2px;
line-height: 1.6;
.confirm-name-text {
color: #ed4014;
font-weight: bold;
user-select: all;
}
}
</style>
+4 -2
View File
@@ -2,8 +2,8 @@ import { MyConfirm } from "@/types/Basics";
import confirm from "./Confirm.vue"; import confirm from "./Confirm.vue";
import { createApp } from "vue"; import { createApp } from "vue";
export default ({ title, msg, info, confirmText, closeText, isLoading, onOk, onCancel }: MyConfirm) => { export default ({ title, msg, info, confirmText, closeText, isLoading, confirmName, danger, onOk, onCancel }: MyConfirm) => {
return new Promise<boolean>((reslove, reject) => { return new Promise<boolean>((reslove, _reject) => {
const instance = createApp(confirm, { const instance = createApp(confirm, {
title, title,
msg, msg,
@@ -11,6 +11,8 @@ export default ({ title, msg, info, confirmText, closeText, isLoading, onOk, onC
confirmText, confirmText,
closeText, closeText,
isLoading, isLoading,
confirmName,
danger,
confirmFn: { confirmFn: {
onOk, onOk,
reslove, reslove,
+90 -2
View File
@@ -1,9 +1,35 @@
<template> <template>
<div> <div class="my-tree">
<div :id="domId" :ref="domId" class="ztree"></div> <div :id="domId" :ref="domId" class="ztree"></div>
</div> </div>
</template> </template>
<script> <script>
/**
* 节点规范化兜底:zTree 判定父节点的规则是「children 是数组即 isParent=true」(jquery.ztree.all.min.js
* 中 `tools.isArray(s) ? (data.nodeIsParent(setting,n,!0), ...)`),因此叶子节点只要被构造成
* `children: []` 就会渲染出 +/− 展开图标——此时再写 isParent:false 也无效(被数组分支覆盖)。
* 这里统一按「空 children 不挂键」,与参考实现 GroupTreeSearchPanel.formatTree 的口径一致。
*/
function normalizeTreeNodes(nodes) {
if (Array.isArray(nodes)) {
nodes.forEach(normalizeTreeNode);
} else if (nodes) {
normalizeTreeNode(nodes);
}
}
function normalizeTreeNode(node) {
if (!node || typeof node !== "object") return;
const kids = node.children;
if (Array.isArray(kids) && kids.length > 0) {
node.isParent = true;
kids.forEach(normalizeTreeNode);
} else {
delete node.children;
node.isParent = false;
}
}
export default { export default {
props: { props: {
treeData: { treeData: {
@@ -17,6 +43,12 @@ export default {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
/** 受控的「全部展开」:true 时展开全部节点。由本组件在树初始化之后套用,
* 避免调用方在数据重载(切组/搜索过滤)时与 initTree 抢时序而丢失展开状态 */
expandAll: {
type: Boolean,
default: false,
},
isCheck: { isCheck: {
typeof: Boolean, typeof: Boolean,
default: false, default: false,
@@ -38,6 +70,12 @@ export default {
}); });
} }
}); });
// 受控「全部展开」:开关切换即时生效(树未初始化时忽略,initTree 会按当前值套用)
this.$watch("expandAll", val => {
if (this.treeObj) {
this.treeObj.expandAll(!!val);
}
});
}, },
mounted() { mounted() {
this.domId = this.uuid(); this.domId = this.uuid();
@@ -65,6 +103,7 @@ export default {
}, },
initTree(_data, _domId) { initTree(_data, _domId) {
const _this = this; const _this = this;
normalizeTreeNodes(_data);
_this.treeSetting.callback.onClick = (event, treeId, treeNode, clickFlag) => { _this.treeSetting.callback.onClick = (event, treeId, treeNode, clickFlag) => {
// console.log(event, treeId, treeNode, clickFlag); // console.log(event, treeId, treeNode, clickFlag);
if (_this.isCheck) { if (_this.isCheck) {
@@ -101,6 +140,10 @@ export default {
}; };
_this.treeObj = $.fn.zTree.init($("#" + _domId), _this.treeSetting, _data); _this.treeObj = $.fn.zTree.init($("#" + _domId), _this.treeSetting, _data);
// 数据重载后重新套用「全部展开」(节点 open 会被新数据重置)
if (_this.expandAll) {
_this.treeObj.expandAll(true);
}
if (_this.autoClick) { if (_this.autoClick) {
if (Array.isArray(this.treeData)) { if (Array.isArray(this.treeData)) {
let id = this.treeData[0].id; let id = this.treeData[0].id;
@@ -132,7 +175,10 @@ export default {
var node = treeObj.getNodesByFilter(node => node.id == id, true); var node = treeObj.getNodesByFilter(node => node.id == id, true);
var pnode = node.getParentNode(); var pnode = node.getParentNode();
treeObj.removeNode(node); treeObj.removeNode(node);
pnode.isParent = true; // 同步父节点的父/叶状态:删掉最后一个子节点后不能继续显示 +/− 展开图标
const hasChild = !!(pnode.children && pnode.children.length);
pnode.isParent = hasChild;
pnode.open = hasChild && pnode.open;
treeObj.updateNode(pnode); treeObj.updateNode(pnode);
this.selectNode(pnode.id); this.selectNode(pnode.id);
}, },
@@ -176,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 = [];
@@ -211,6 +284,21 @@ export default {
</script> </script>
<style> <style>
/* 高度链:父级定高时 .ztree 成为唯一滚动体(滚动条只在树节点区域);
父级高度 auto(如卡片内按内容展开的树)时 height:100% 退化为 auto,表现为按内容展开,不受影响 */
.my-tree {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.my-tree > .ztree {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.ztree-search-highlight { .ztree-search-highlight {
background-color: #ff6600 !important; background-color: #ff6600 !important;
} }
+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>
+24 -7
View File
@@ -1,16 +1,18 @@
<template> <template>
<div class="table-region" ref="regionRef">
<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>
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps"> <template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" /> <slot :name="slotName" v-bind="slotProps" />
</template> </template>
</Table> </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
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts" setup>
/* 默认头像三态组件(YschTheme 公共源,scripts/sync-apps.mjs 下发三端 src/components/UserAvatar.vue):
头像 URL 非空 → <img>;否则 style==="text" 且能取到字 → 首字;否则 → 剪影(纯 CSS/SVG,零资源文件)。
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
const props = withDefaults(
defineProps<{
/** 头像 URL:非空直接 <img>(三态第一优先) */
src?: string | null;
/** 取字来源(首字态用):经 pickAvatarText 判定,取不到字回落剪影 */
name?: string | null;
/** 直径 px;font-size = round(size * 0.44) */
size?: number;
/** 配色场景:light=页面浅底(品牌实底+白内容);brand=header 品牌色底(剪影 S1 玻璃底 / 首字 S2 白底反色,§10.3 分档) */
placement?: "light" | "brand";
}>(),
{ src: null, name: null, size: 28, placement: "light" },
);
const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
/* 外观偏好异步补齐(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:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */
const showText = computed(() => style.value === "text" && !!text.value);
const rootClass = computed(() => [
"yh-avatar",
props.placement === "brand" ? "yh-avatar--brand" : "yh-avatar--light",
{ "is-text": showText.value },
]);
const rootStyle = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
fontSize: `${Math.round(props.size * 0.44)}px`,
}));
</script>
<template>
<span :class="rootClass" :style="rootStyle">
<img v-if="src" class="yh-avatar__img" :src="src" alt="" />
<template v-else-if="showText">{{ text }}</template>
<svg v-else class="yh-avatar__sil" viewBox="0 0 1024 1024" aria-hidden="true">
<circle cx="512" cy="399" r="174" />
<ellipse cx="512" cy="946" rx="420" ry="240" />
</svg>
</span>
</template>
<style scoped>
.yh-avatar {
position: relative;
border-radius: 50%;
overflow: hidden;
display: inline-grid;
place-items: center;
flex: 0 0 auto;
font-weight: 600;
line-height: 1;
user-select: none;
}
.yh-avatar__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* 剪影 SVG:容器圆 overflow:hidden 裁切肩部溢出(禁用 clipPath);overflow:visible 放行 viewBox 外形状 */
.yh-avatar__sil {
display: block;
width: 100%;
height: 100%;
overflow: visible;
fill: currentColor;
}
/* 页面内(浅底)——品牌实底 + 白内容 */
.yh-avatar--light {
background: var(--brand, #577afb);
color: #fff;
}
/* header(品牌色底)——与页面内反向:剪影档 = S1 半透明白,首字档 = S2 纯白底 + 品牌色字 */
.yh-avatar--brand {
background: rgba(255, 255, 255, 0.22);
color: #fff;
}
.yh-avatar--brand.is-text {
background: #fff;
color: var(--brand, #577afb);
}
</style>
+46 -12
View File
@@ -1,9 +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 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')");
@@ -11,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[]>([]);
@@ -137,11 +161,12 @@ const linkMenu = (page: string) => {
}; };
const linkUserInfo = () => { const linkUserInfo = () => {
window.location.href = baseHost + "/v/userinfo"; // 头像/姓名点击统一跳 UserHome 设置页「个人信息」pane(默认头像轮 2026-09-30,原 /v/userinfo 已下线语义)
window.location.href = baseHost + "/userhome/front/index.html#/settings?pane=profile";
}; };
const logout = () => { const logout = () => {
let url = window.location.href; let url = window.location.href;
url = "/cas/logout?service=" + url; url = "/logout?service=" + url;
window.location.href = ssoHost + url; window.location.href = ssoHost + url;
}; };
const childChange = (index: number) => { const childChange = (index: number) => {
@@ -155,7 +180,8 @@ function linkHomePage() {
if (!bschUtil.getIsSelf()) { if (!bschUtil.getIsSelf()) {
bschUtil.goBackDesktop(); bschUtil.goBackDesktop();
} else { } else {
window.location.href = baseHost + "/BschHome/front/index.html"; // 旧 BschHome 已下线(线上 404),首页统一指向用户中心 userhome
window.location.href = baseHost + "/userhome/front/index.html";
} }
} }
</script> </script>
@@ -163,22 +189,19 @@ 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', isPanel ? 'active' : '']" @click="showPanel"> <span class="logo-badge fl"><img :src="logoUrl" alt="云海" @error="onLogoError" /></span>
<i class="iconfont icon-apps"></i>
</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>
<div class="header-aside fr"> <div class="header-aside fr">
<img :src="userInfo.avatar" class="fl align-middle" @click="linkUserInfo" /> <UserAvatar class="fl align-middle" :src="userInfo.avatar" :name="userInfo.name" :size="28" placement="brand" @click="linkUserInfo" />
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo"> <a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
{{ userInfo.name }} {{ userInfo.name }}
</a> </a>
<div class="delimiter fl align-middle"></div> <div class="delimiter fl align-middle"></div>
<a href="javascript:;" @click="logout" class="logout fl align-middle" title="退出登陆"> <a href="javascript:;" @click="logout" class="logout fl align-middle" title="退出登陆">
<i class="iconfont icon-logout"></i> <SvgIcon name="logout" :size="18" color="#fff" />
</a> </a>
</div> </div>
</header> </header>
@@ -248,7 +271,7 @@ function linkHomePage() {
</li> </li>
</ul> </ul>
<div class="link-panel"> <div class="link-panel">
<a :href="baseHost + '/BschHome/front/index.html#/apps'" target="_bank" rel="noopener noreferrer"> <a :href="baseHost + '/userhome/front/index.html#/apps'" target="_bank" rel="noopener noreferrer">
全部应用 全部应用
<i>→</i> <i>→</i>
</a> </a>
@@ -257,3 +280,14 @@ function linkHomePage() {
</transition> </transition>
</div> </div>
</template> </template>
<style scoped>
/* 头部两个 ysch 单 svg 图标(SvgIcon):
layout.css 的 .header-aside .logout i / .icon-btn i 只命中 <i>,换 <svg> 后不再命中,
颜色与尺寸已由组件 props(#fff / 18、22)给出,此处只补布局——
<svg> 默认 inline 会带基线降部空隙,改 block 后与 Notice 的 mask 图标观感一致。
(Vue3 scoped 下子组件根节点会带父级 data-v,故 .logout svg 可命中) */
.header-aside .logout svg {
display: block;
}
</style>
+3 -2
View File
@@ -13,8 +13,9 @@ import mittBus from "@/utils/mitt";
import "view-ui-plus/dist/styles/viewuiplus.css"; import "view-ui-plus/dist/styles/viewuiplus.css";
import "@/assets/styles/main.scss"; import "@/assets/styles/main.scss";
// 本地化的布局/主题样式(原远程 base.layout.beta / _iview_override / default-theme 合并) // 布局/主题/图标(base.layout.css + iview.override.css + icons.css)由中央静态通道下发:
import "@/assets/styles/layout.css"; // index.html 的 yh-theme-init.js 在 DOMContentLoaded 后按 manifest 版本追加 <link>,
// 天然排在本行组件库样式与 main.scss 之后(原本地 layout.css 是通道旧版全量拷贝,已删)
//导入自定义指令 //导入自定义指令
import directive from "@/utils/directive/index"; import directive from "@/utils/directive/index";
+10 -7
View File
@@ -12,7 +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 PushTest from "@/views/push/PushTest.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 为空/未声明 = 登录即可访问
@@ -106,18 +106,21 @@ 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",
component: PushLog, component: PushLog,
meta: { title: "推送日志", icon: "icon-list", modId: 1112, keepAlive: true, roles: [UserRoleEnum.SuperAdmin] }, meta: { title: "推送日志", icon: "icon-list", modId: 1112, keepAlive: true, roles: [UserRoleEnum.SuperAdmin] },
}, },
{
path: "/push-test",
name: "PushTest",
component: PushTest,
meta: { title: "测试推送", icon: "icon-list", modId: 1113, roles: [UserRoleEnum.SuperAdmin] },
},
// ===== 操作日志 ===== // ===== 操作日志 =====
{ {
path: "/oplog", path: "/oplog",
+4
View File
@@ -105,6 +105,10 @@ export interface MyConfirm {
isLoading?: boolean; isLoading?: boolean;
confirmText?: string; confirmText?: string;
closeText?: string; closeText?: string;
/** 输名确认:值与 msg 对象名一致才允许点确认键(危险操作删除模,对齐 projman) */
confirmName?: string;
/** 红色确认键(危险操作) */
danger?: boolean;
onOk?: Function; onOk?: Function;
onCancel?: Function; onCancel?: Function;
} }
+8 -1
View File
@@ -14,7 +14,14 @@ function closeLoading() {
} }
} }
const baseUrl = conf.serviceHost; /**
* mock 模式必须短路成同源相对路径,否则 alova 会带着 serviceHost(本地 8060 / 线上域)跨源直连,
* vite-plugin-mock 的中间件根本拦不到——这正是 `VITE_IS_MOCK=true` 开了却仍报 "Failed to fetch" 的根因
* (axios 那条老链路的 api.config.ts 认 isMock,迁到 alova 后这一处漏了)。
* 同源之后:mock 注册过的接口(/api/base/GetUserInfo、GetUserMod)由 mock 应答,
* 未注册的接口继续经 vite.config 的 /api 代理透传给后端(见 YH_API_PROXY_TARGET)。
*/
const baseUrl = conf.isMock ? "" : conf.serviceHost;
/* 创建带 View Design UI 的 Alova 实例 */ /* 创建带 View Design UI 的 Alova 实例 */
export const alovaInstance = createBschAlova(baseUrl, { export const alovaInstance = createBschAlova(baseUrl, {
+2 -1
View File
@@ -19,7 +19,8 @@ function closeLoading() {
} }
// const baseUrl = conf.serviceHost + "/api"; // const baseUrl = conf.serviceHost + "/api";
const baseUrl = conf.serviceHost + "/mobiapi"; // 同 alova-iview:mock 模式短路成同源,否则 vite-plugin-mock / dev 代理都拦不到跨源请求
const baseUrl = (conf.isMock ? "" : conf.serviceHost) + "/mobiapi";
/* 创建带 Vant UI 的 Alova 实例 */ /* 创建带 Vant UI 的 Alova 实例 */
export const alovaInstance = createBschAlova(baseUrl, { export const alovaInstance = createBschAlova(baseUrl, {
injectHeaders(method) { injectHeaders(method) {
+94
View File
@@ -0,0 +1,94 @@
/**
* 默认头像样式(首字 / 剪影)公共源(YschTheme,scripts/sync-apps.mjs 下发三端 src/utils/avatar.ts)。
* 取值链与主题同构(theme-core.js resolveEffectiveThemeState 的合成链写法):
* URL ?yh-avatar=text|silhouette(仅预览/排障覆盖:只影响读、绝不写回)
* > localStorage["yh-avatar"](用户自选,纯字符串)
* > localStorage["yh-avatar-sch-default"](学校默认,UserHome 登录后写入)
* > "text"(系统默认)
* 值域恒为 "text" | "silhouette",键缺失/值非法均静默回落下一级,不报错不阻塞渲染。
* 渲染三态与取字规则的唯一契约见 UserHome《设置页修复与默认头像落地方案》§10.1/§10.2。
*/
export type AvatarStyle = "text" | "silhouette";
interface StorageLike {
getItem(k: string): string | null;
setItem(k: string, v: string): void;
removeItem(k: string): void;
}
export const AVATAR_STORAGE_KEY = "yh-avatar";
/** 学校默认头像存储键(UserHome 登录时写入;值格式与用户键相同,仅无"用户自选"语义) */
export const SCHOOL_DEFAULT_AVATAR_STORAGE_KEY = "yh-avatar-sch-default";
export const AVATAR_STYLES: readonly AvatarStyle[] = ["text", "silhouette"];
export const DEFAULT_AVATAR_STYLE: AvatarStyle = "text";
/** 值合法才采用(用户键/学校键同一判据;脏数据不生效) */
function readAvatarStyleOrNull(raw: string | null): AvatarStyle | null {
return raw === "text" || raw === "silhouette" ? raw : null;
}
/** URL 覆盖参数(?yh-avatar=text|silhouette):非法值忽略,且只影响"读哪一档"、绝不写回 localStorage */
function readAvatarOverride(): AvatarStyle | null {
try {
const search = (globalThis.location && globalThis.location.search) || "";
const m = new RegExp("[?&]yh-avatar=([^&]*)").exec(search);
return m ? readAvatarStyleOrNull(decodeURIComponent(m[1])) : null;
} catch {
/* 无 location 环境(测试/SSR):无覆盖 */
return null;
}
}
/**
* 生效样式合成(与 resolveEffectiveThemeState 同构):
* URL 覆盖 > localStorage["yh-avatar"](用户自选)> localStorage["yh-avatar-sch-default"](学校默认)
* > DEFAULT_AVATAR_STYLE。
*/
export function resolveAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): AvatarStyle {
let style: AvatarStyle | null = null;
if (storage) {
style = readAvatarStyleOrNull(storage.getItem(AVATAR_STORAGE_KEY));
if (!style) style = readAvatarStyleOrNull(storage.getItem(SCHOOL_DEFAULT_AVATAR_STORAGE_KEY));
}
if (!style) style = DEFAULT_AVATAR_STYLE;
return readAvatarOverride() || style;
}
/** 保存用户自选(直写 localStorage,无响应式;调用方操作后自行刷新 UI) */
export function saveAvatarStyle(style: AvatarStyle, storage: StorageLike | undefined = globalThis.localStorage): void {
try {
storage?.setItem(AVATAR_STORAGE_KEY, style);
} catch {
/* 隐私模式/配额失败静默:仅丢持久化,不丢本次会话生效 */
}
}
/** 跟随学校默认 = 删用户自选键(之后回落学校默认/系统默认) */
export function clearAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): void {
try {
storage?.removeItem(AVATAR_STORAGE_KEY);
} catch {
/* 隐私模式静默 */
}
}
/**
* 取字(顺序判定,先命中先返回):含 "@" 先截 "@" 前段(邮箱取首字母);
* 首字符必须用展开 [...s][0](禁用 charAt(0),避免代理对截半出乱码)。
* ASCII 字母转大写;纯数字回落 ""(剪影);符号/emoji/空白回落 ""(剪影);其余(CJK 等)原样。
*/
export function pickAvatarText(name: string | null | undefined): string {
let s = String(name ?? "");
const at = s.indexOf("@");
if (at > -1) s = s.slice(0, at);
const ch = [...s.trim()][0];
if (ch === undefined) return "";
if (/^[A-Za-z]$/.test(ch)) return ch.toUpperCase();
if (/^[0-9]$/.test(ch)) return "";
if (/^[\p{P}\p{S}\s]$/u.test(ch)) return "";
return ch;
}
+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;
}
+26 -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 {
/** /**
* 格式化日期 * 格式化日期
@@ -67,6 +67,20 @@ export default class Utils {
} else return ""; } else return "";
} }
/**
* 在线预览大小上限(字节):日志大字段(请求体/响应)超过此值时隐藏"在线预览"、只保留"下载"。
* 浏览器 JSON 美化 + <pre> 渲染在数百 KB 起会卡顿,50KB 为体验无损的保守阈值。
*/
public static readonly PREVIEW_MAX_BYTES = 50 * 1024;
/**
* 是否允许在线预览:有内容(size > 0)且字节数不超过阈值。
* size 为 null/undefined/0(未记录请求体、saveResponse=false、响应为空)→ 不可预览,仅下载。
*/
public static canPreview(size?: number | null): boolean {
return size != null && size > 0 && size <= Utils.PREVIEW_MAX_BYTES;
}
/** /**
* 删除指定键 * 删除指定键
*/ */
@@ -76,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,
@@ -85,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,
@@ -116,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),
};
}
+49
View File
@@ -0,0 +1,49 @@
/**
* 树勾选级联工具
*
* 级联由 zTree 原生 chkboxType 承担(两处页面均配置 {Y:"ps", N:"s"}):
* Y="ps" 勾选任意节点 -> 其父/祖先全部勾选(p)+ 其下子孙全部勾选(s)
* N="s" 取消节点 -> 其下子孙全部取消(s),不影响其父/祖先
* 即:勾选子 -> 祖先全部勾选;勾选父 -> 子孙全部勾选;取消父 -> 子孙全部取消;取消子 -> 父/祖先不受影响。
*
* zTree 原生级联只作用于 zTree 节点,本工具负责把应用子树最终的勾选态同步回本地数据
* (AppItem.modules),作为模块明细 diff 提交 / 全量保存的依据。
*/
export type ModuleVo = {
id?: number;
name?: string;
checked?: boolean;
inheritFlag?: boolean;
children?: ModuleVo[];
};
export function walkModules(nodes: ModuleVo[] | undefined, fn: (n: ModuleVo) => void): void {
(nodes || []).forEach((n) => {
fn(n);
walkModules(n.children, fn);
});
}
/**
* 原生级联完成后调用:把 zTree 中某应用子树的最终勾选态同步回本地 AppItem.modules。
* @param app 本地应用数据(提交依据)
* @param anyNode zTree 内该应用下任意节点(应用节点或模块节点),沿父链回溯到应用根节点
*/
export function syncAppModulesFromTree(app: { modules?: ModuleVo[] } | null | undefined, anyNode: any): void {
if (!app?.modules || !anyNode) return;
let root: any = anyNode;
while (root && root.nodeType !== "app") {
root = root.getParentNode ? root.getParentNode() : null;
}
if (!root) return;
const checkedMap = new Map<number, boolean>();
(function collect(n: any) {
if (n.nodeType === "module" && n.rawId != null) checkedMap.set(Number(n.rawId), !!n.checked);
(n.children || []).forEach(collect);
})(root);
walkModules(app.modules, (m) => {
const v = checkedMap.get(Number(m.id));
if (v !== undefined) m.checked = v;
});
}
+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;
+167 -77
View File
@@ -38,12 +38,19 @@
<Icon class="collapse-icon" <Icon class="collapse-icon"
:type="collapsedMap[String(item.cat.id)] ? 'ios-arrow-forward' : 'ios-arrow-down'" /> :type="collapsedMap[String(item.cat.id)] ? 'ios-arrow-forward' : 'ios-arrow-down'" />
<span class="cat-name" :title="item.cat.name">{{ item.cat.name }}</span> <span class="cat-name" :title="item.cat.name">{{ item.cat.name }}</span>
<span class="cat-count">{{ item.treeData.length }}个应用</span> <span class="cat-spacer"></span>
<span class="inherit-cap">子组继承</span> <!-- 开关外层 @click.stop:否则点击会冒泡到卡片头部触发折叠 -->
<span class="expand-all" @click.stop>
<span class="expand-all-label">全部展开:</span>
<Switch size="small" v-model="expandAllMap[String(item.cat.id)]" />
</span>
<span class="inherit-cap" title="勾选后子组继承本次授权,不勾选仅本组生效">子组继承</span>
</div> </div>
<div v-show="!collapsedMap[String(item.cat.id)]" class="cat-card-body"> <div v-show="!collapsedMap[String(item.cat.id)]" class="cat-card-body">
<MyTree :treeData="item.treeData" :setting="treeSetting" :autoClick="false" <MyTree :treeData="item.treeData" :setting="treeSetting" :autoClick="false" :isCheck="true"
@on-tree-check="onTreeCheck" /> :expandAll="!!expandAllMap[String(item.cat.id)]" @on-tree-check="onTreeCheck" />
<!-- 「N个应用」计数:绝对定位到整个卡片的右下角(与子组继承复选框同列) -->
<span class="cat-count">{{ item.treeData.length }}个应用</span>
</div> </div>
</div> </div>
</div> </div>
@@ -62,11 +69,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, reactive, ref, shallowRef, watch } from "vue"; import { computed, onMounted, reactive, ref, shallowRef, watch } from "vue";
import { Message } from "view-ui-plus";
import Apis from "@/api"; import Apis from "@/api";
import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue"; import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
import MyTree from "@/components/MyTree.vue"; import MyTree from "@/components/MyTree.vue";
import { useDefaultStore } from "@/store"; import { useDefaultStore } from "@/store";
import { syncAppModulesFromTree, walkModules } from "@/utils/treeCascade";
defineOptions({ name: "GroupAuth" }); defineOptions({ name: "GroupAuth" });
@@ -83,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[];
}; };
@@ -107,6 +118,7 @@ type AuthzTreeNode = {
nodeType: "app" | "module"; nodeType: "app" | "module";
rawId: number; rawId: number;
appId: number; appId: number;
isParent?: boolean;
children?: AuthzTreeNode[]; children?: AuthzTreeNode[];
}; };
@@ -133,6 +145,9 @@ const searchKeyword = ref("");
const appliedKeyword = ref(""); const appliedKeyword = ref("");
const collapsedMap = reactive<Record<string, boolean>>({}); const collapsedMap = reactive<Record<string, boolean>>({});
/** 各应用类型卡片的「全部展开」开关(默认关闭):状态随卡片独立保存,数据重载后仍生效 */
const expandAllMap = reactive<Record<string, boolean>>({});
const fallbackModeItems: DictItem[] = [ const fallbackModeItems: DictItem[] = [
{ k: 0, v: "追加" }, { k: 0, v: "追加" },
{ k: 1, v: "覆盖" }, { k: 1, v: "覆盖" },
@@ -210,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 {
@@ -230,33 +248,41 @@ function isModuleAllInherit(node: AppModuleVo): boolean {
return (node.children || []).every((c) => isModuleAllInherit(c)); return (node.children || []).every((c) => isModuleAllInherit(c));
} }
// 空 children 不挂键:zTree 只要见到 children 是数组就强制 isParent=true 而渲染 +/−(叶子也会出现展开图标)
function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode { function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
return { const kids = (m.children || []).map((c) => buildModuleNode(c, appId));
const node: 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),
appId, appId,
children: (m.children || []).map((c) => buildModuleNode(c, appId)), isParent: kids.length > 0,
}; };
if (kids.length > 0) node.children = kids;
return node;
} }
function buildAppNode(app: AppItem): AuthzTreeNode { function buildAppNode(app: AppItem): AuthzTreeNode {
const appId = Number(app.id); const appId = Number(app.id);
return { const kids = (app.modules || []).map((m) => buildModuleNode(m, appId));
const node: AuthzTreeNode = {
id: `app_${appId}`, id: `app_${appId}`,
name: String(app.name || ""), name: String(app.name || ""),
open: true, open: false, // 一级默认闭合,点击才展开模块
checked: isAppChecked(app), checked: isAppChecked(app),
inheritFlag: isAppInherit(app), inheritFlag: isAppInherit(app),
nodeType: "app", nodeType: "app",
rawId: appId, rawId: appId,
appId, appId,
children: (app.modules || []).map((m) => buildModuleNode(m, appId)), isParent: kids.length > 0,
}; };
if (kids.length > 0) node.children = kids;
return node;
} }
// 分类 -> zTree 数据(应用为一级节点,模块为子节点);weight 用于三列均衡 // 分类 -> zTree 数据(应用为一级节点,模块为子节点);weight 用于三列均衡
@@ -330,8 +356,9 @@ const treeSetting = {
check: { check: {
enable: true, enable: true,
chkStyle: "checkbox", chkStyle: "checkbox",
// 不自动级联:模块逐条独立授权;应用节点勾选后由代码手动同步子节点 // 原生级联即可满足需求:
chkboxType: { Y: "", N: "" }, // Y="ps" 勾选子->父/祖先全部勾选、勾选父->子孙全部勾选;N="s" 取消父->子孙全部取消、取消子->父/祖先不受影响
chkboxType: { Y: "ps", N: "s" },
}, },
callback: {}, callback: {},
}; };
@@ -342,18 +369,6 @@ function getTreeObjByNode(node: any) {
return $?.fn?.zTree?.getZTreeObj(treeId) || null; return $?.fn?.zTree?.getZTreeObj(treeId) || null;
} }
/** 模块勾选变化后,按本地数据回算应用节点的勾选/继承态并同步到 zTree */
function syncAppNodeState(treeObj: any, appId: number) {
if (!treeObj) return;
const app = findLocalApp(appId);
if (!app) return;
const appNode = treeObj.getNodesByFilter((n: any) => n.id === `app_${appId}`, true);
if (!appNode) return;
appNode.checked = isAppChecked(app);
appNode.inheritFlag = isAppInherit(app);
treeObj.checkNode(appNode, appNode.checked, false);
}
/** 按 zTree 当前节点状态刷新所有"子组继承"复选框的勾选与禁用态 */ /** 按 zTree 当前节点状态刷新所有"子组继承"复选框的勾选与禁用态 */
function syncInheritDoms(treeObj: any) { function syncInheritDoms(treeObj: any) {
const $ = jq(); const $ = jq();
@@ -411,25 +426,15 @@ function findLocalApp(appId: number): AppItem | null {
return null; return null;
} }
function walkModules(nodes: AppModuleVo[], fn: (n: AppModuleVo) => void) { // walkModules / 本地勾选态同步(syncAppModulesFromTree)统一在 utils/treeCascade.ts
for (const node of nodes) {
fn(node);
walkModules(node.children || [], fn);
}
}
function setLocalAppChecked(appId: number, checked: boolean) {
const app = findLocalApp(appId);
if (!app) return;
walkModules(app.modules || [], (n) => {
n.checked = checked;
if (!checked) n.inheritFlag = false;
});
}
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;
}); });
@@ -445,49 +450,91 @@ function setLocalModuleState(appId: number, moduleId: number, patch: Partial<App
// ---------------- 授权操作 ---------------- // ---------------- 授权操作 ----------------
/** 勾选/取消勾选:应用节点 => moduleId=0 整应用处理;模块节点 => 单模块处理 */ /**
* 勾选/取消勾选:级联由 zTree 原生 chkboxType(Y:"ps" N:"s")完成,
* 这里把该应用最终勾选态同步回本地数据(提交依据),仅按模块明细 diff 提交 追加/删除。
*/
async function onTreeCheck(node: any) { async function onTreeCheck(node: any) {
const groupId = selectedGroupId.value; const groupId = selectedGroupId.value;
if (!groupId || !node) return; if (!groupId || !node) return;
const checked = !!node.checked;
const isApp = node.nodeType === "app";
const treeObj = getTreeObjByNode(node); const treeObj = getTreeObjByNode(node);
const app = findLocalApp(node.appId);
if (!app) {
await reloadCurrent();
return;
}
// 无子模块应用(如「一周安排」):授权粒度即整个应用,落 moduleId=0 的整应用记录。
// 这类应用没有可 diff 的模块明细,必须单独走应用级提交,否则勾选只是个视觉状态、刷新即失效。
const appModules = app.modules || [];
if (appModules.length === 0) {
const checked = !!node.checked;
try { try {
if (checked !== !!app.checked) {
app.checked = checked;
await Apis.GroupAuthz.setGroupModule({ await Apis.GroupAuthz.setGroupModule({
data: [{ data: [{
groupId, groupId,
appId: node.appId, appId: node.appId,
moduleId: isApp ? 0 : node.rawId, moduleId: 0,
mode: checked ? addMode.value : deleteMode.value, mode: checked ? addMode.value : deleteMode.value,
isAll: checked && node.inheritFlag ? 1 : 0, isAll: 0,
}], }],
cacheFor: null, cacheFor: null,
}); });
if (isApp) {
setLocalAppChecked(node.appId, checked);
// 应用节点整批操作:手动同步 zTree 子节点勾选态与继承标记
if (treeObj) {
const affected = treeObj.transformToArray([node]) || [];
affected.forEach((n: any) => {
if (n !== node) treeObj.checkNode(n, checked, false);
n.checked = checked;
if (!checked) n.inheritFlag = false;
});
}
Message.success(checked ? "已授权该应用全部模块" : "已取消该应用全部模块授权");
} else {
if (!checked) node.inheritFlag = false;
setLocalModuleState(node.appId, node.rawId, checked ? { checked: true } : { checked: false, inheritFlag: false });
syncAppNodeState(treeObj, node.appId);
} }
syncInheritDoms(treeObj); syncInheritDoms(treeObj);
} catch (e: any) { } catch (e: any) {
console.warn(e?.message || "操作失败"); console.warn(e?.message || "操作失败");
await reloadCurrent(); await reloadCurrent();
} }
return;
}
// 级联前快照:用于计算本次实际变化的模块明细
const before = new Map<number, boolean>();
walkModules(app.modules, (m) => before.set(Number(m.id), !!m.checked));
try {
// zTree 原生级联已生效,同步最终勾选态到本地数据
syncAppModulesFromTree(app, node);
syncInheritDoms(treeObj);
const after = new Map<number, boolean>();
walkModules(app.modules, (m) => after.set(Number(m.id), !!m.checked));
const changes = buildModuleDiff(groupId, node.appId, before, after);
if (changes.length > 0) {
await Apis.GroupAuthz.setGroupModule({ data: changes, cacheFor: null });
}
} catch (e: any) {
console.warn(e?.message || "操作失败");
await reloadCurrent();
}
}
/** 对比前后模块勾选快照,仅输出变化的模块(追加/删除);父/祖先节点只做展示,不进提交 */
function buildModuleDiff(
groupId: number,
appId: number,
before: Map<number, boolean>,
after: Map<number, boolean>
): Array<Record<string, any>> {
const changes: Array<Record<string, any>> = [];
const ids = new Set<number>([...before.keys(), ...after.keys()]);
ids.forEach((id) => {
const prev = before.get(id);
const next = after.get(id);
if (prev === next) return;
changes.push({
groupId,
appId,
moduleId: id,
mode: next ? addMode.value : deleteMode.value,
isAll: 0,
});
});
return changes;
} }
/** 子组继承开关:应用节点 => moduleId=0 整应用处理;模块节点 => 单模块处理 */ /** 子组继承开关:应用节点 => moduleId=0 整应用处理;模块节点 => 单模块处理 */
@@ -595,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;
@@ -656,29 +703,61 @@ onMounted(async () => {
font-size: 13px; font-size: 13px;
color: #333; color: #333;
margin-left: 6px; margin-left: 6px;
/* 「N个应用」计数已绝对定位脱离主行,主行只剩分类名 + 全部展开 + 子组继承 */
flex: 0 1 auto;
min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.cat-count { .cat-count {
/* 计数贴在整张卡片右下角:与右侧「子组继承」复选框同列,脱离 tree 的文档流 */
position: absolute;
right: 14px;
bottom: 6px;
color: #999; color: #999;
font-size: 12px; font-size: 12px;
margin-left: 8px; line-height: 18px;
flex: 1;
white-space: nowrap; white-space: nowrap;
} }
/* 弹性空隙:把「全部展开 / 子组继承」推到卡片头右端,与分类名拉开距离 */
.cat-spacer {
flex: 1 1 0;
min-width: 0;
}
.inherit-cap { .inherit-cap {
font-size: 12px; font-size: 12px;
color: #999; color: #999;
flex-shrink: 0; flex-shrink: 0;
margin-right: 6px; margin-left: 10px;
padding: 0 6px;
line-height: 18px;
background: #f0f2f5;
border-radius: 2px;
cursor: default;
}
.expand-all {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 4px;
margin-left: 12px;
}
.expand-all-label {
font-size: 12px;
color: #999;
white-space: nowrap;
} }
.cat-card-body { .cat-card-body {
padding: 6px 8px; /* 底部多留一行:容纳右下角的「N个应用」计数,避免压住最后一行节点 */
/* 子组继承复选框的绝对定位锚点:保证所有层级节点的复选框右对齐成一列 */ padding: 6px 8px 24px;
/* 子组继承复选框 / 计数角标的绝对定位锚点:保证两者右对齐成一列 */
position: relative; position: relative;
} }
@@ -689,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) {
@@ -715,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;
+227 -55
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="main-wrapper tree-wrapper"> <div class="main-wrapper tree-wrapper permission-view">
<div class="tree-container"> <div class="tree-container permission-tree">
<Input v-model="userKeyword" placeholder="输入用户,回车查找" suffix="ios-search" search clearable <Input v-model="userKeyword" placeholder="输入用户,回车查找" suffix="ios-search" search clearable
class="search-input" @on-search="onSearchUser" @on-clear="onClearUser" /> class="search-input" @on-search="onSearchUser" @on-clear="onClearUser" />
<GroupTreeSearchPanel ref="groupTreeRef" :treeApi="fetchGroupTree" :autoClick="true" <GroupTreeSearchPanel ref="groupTreeRef" :treeApi="fetchGroupTree" :autoClick="true"
@@ -46,7 +46,8 @@
<div class="side-body"> <div class="side-body">
<div v-if="treeLoading" class="loading-tip small">加载中...</div> <div v-if="treeLoading" class="loading-tip small">加载中...</div>
<div v-else-if="deptTree.length === 0" class="empty-tip small">暂无部门归属</div> <div v-else-if="deptTree.length === 0" class="empty-tip small">暂无部门归属</div>
<MyTree v-else :treeData="deptTree" :setting="treeSetting" /> <MyTree v-else :treeData="deptTree" :setting="treeSetting" :autoClick="false"
@on-tree-click="onDeptNodeClick" />
</div> </div>
</div> </div>
<div class="side-block"> <div class="side-block">
@@ -54,17 +55,26 @@
<div class="side-body"> <div class="side-body">
<div v-if="treeLoading" class="loading-tip small">加载中...</div> <div v-if="treeLoading" class="loading-tip small">加载中...</div>
<div v-else-if="roleTree.length === 0" class="empty-tip small">暂无所属角色</div> <div v-else-if="roleTree.length === 0" class="empty-tip small">暂无所属角色</div>
<MyTree v-else :treeData="roleTree" :setting="treeSetting" /> <MyTree v-else :treeData="roleTree" :setting="treeSetting" :autoClick="false"
@on-tree-click="onRoleNodeClick" />
</div> </div>
</div> </div>
</div> </div>
<!-- 右列:已授权应用(原有) --> <!-- 右列:已授权应用(原有);支持三种视图:用户聚合 / 部门树节点 / 角色树节点 -->
<div class="perm-main"> <div class="perm-main">
<div class="side-title">已授权应用</div> <div class="side-title">
<span class="side-title-text">{{ permMainTitle }}</span>
<!-- 应用层默认折叠,这里提供一键展开/收起(事件不冒泡到标题行) -->
<span class="expand-all" @click.stop>
<span class="expand-all-label">全部展开:</span>
<Switch size="small" v-model="permExpandAll" />
</span>
</div>
<div class="side-body"> <div class="side-body">
<div v-if="treeLoading" class="loading-tip">权限加载中...</div> <div v-if="permLoading" class="loading-tip">权限加载中...</div>
<div v-else-if="permissionTree.length === 0" class="empty-tip">该用户暂无已授权应用模块</div> <div v-else-if="permissionTree.length === 0" class="empty-tip">{{ permEmptyTip }}</div>
<MyTree v-else ref="permTreeRef" :treeData="permissionTree" :setting="treeSetting" /> <MyTree v-else ref="permTreeRef" :treeData="permissionTree" :setting="treeSetting"
:expand-all="permExpandAll" />
</div> </div>
</div> </div>
</div> </div>
@@ -74,7 +84,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from "vue"; import { ref, computed } from "vue";
import { Input } from "view-ui-plus"; import { Input } from "view-ui-plus";
import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue"; import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
import MyTree from "@/components/MyTree.vue"; import MyTree from "@/components/MyTree.vue";
@@ -146,6 +156,28 @@ const deptTree = ref<any[]>([]);
const roleTree = ref<any[]>([]); const roleTree = ref<any[]>([]);
const userLoading = ref(false); const userLoading = ref(false);
const treeLoading = ref(false); const treeLoading = ref(false);
// 右列「已授权应用」独立 loading:部门/角色节点点击只刷新右列,不能把左列两棵树刷成加载中
const permLoading = ref(false);
// 右列当前展示上下文:user=选中用户的聚合权限 / group=部门树节点的组授权 / role=角色树节点的角色授权
const permScope = ref<{ type: "user" | "group" | "role"; id?: number; label: string }>({
type: "user",
label: "",
});
// 已授权应用面板的「全部展开」开关(默认关闭):应用层默认折叠,需按需展开模块
const permExpandAll = ref(false);
const permMainTitle = computed(() => {
if (permScope.value.type === "group") return `已授权应用(部门:${permScope.value.label})`;
if (permScope.value.type === "role") return `已授权应用(角色:${permScope.value.label})`;
return "已授权应用";
});
const permEmptyTip = computed(() => {
if (permScope.value.type === "group") return "该部门暂无已授权应用模块";
if (permScope.value.type === "role") return "该角色暂无已授权应用模块";
return "该用户暂无已授权应用模块";
});
const treeSetting = ref({ const treeSetting = ref({
view: { dblClickExpand: false, showIcon: false, showLine: true }, view: { dblClickExpand: false, showIcon: false, showLine: true },
@@ -180,6 +212,7 @@ function clearPermPanels() {
permissionTree.value = []; permissionTree.value = [];
deptTree.value = []; deptTree.value = [];
roleTree.value = []; roleTree.value = [];
permScope.value = { type: "user", label: "" };
} }
async function loadGroupUsers(groupId?: number) { async function loadGroupUsers(groupId?: number) {
@@ -234,13 +267,16 @@ function onClearUser() {
async function onSelectUser(user: UserItem) { async function onSelectUser(user: UserItem) {
selectedUser.value = user; selectedUser.value = user;
treeLoading.value = true; treeLoading.value = true;
// 切换用户后右列回到「用户聚合权限」视图(覆盖可能存在的部门/角色节点视图)
permScope.value = { type: "user", id: user.id, label: "" };
const userId = user.id as number; const userId = user.id as number;
// 行自带组ID(多组用户每个组一行)优先;组列表模式回退左侧选中组 // 行自带组ID(多组用户每个组一行)优先;组列表模式回退左侧选中组
const groupId = user.groupId ?? (selectedGroup.value?.id ? Number(selectedGroup.value.id) : undefined); const groupId = user.groupId ?? (selectedGroup.value?.id ? Number(selectedGroup.value.id) : undefined);
// 三块数据并行加载,互不阻塞 // 三块数据并行加载,互不阻塞
// userGroupTree 为新增接口:apiDefinitions 未重新生成(npm run api:gen)时方法不存在,降级为友好报错 // userGroupTree 为新增接口:apiDefinitions 未重新生成(npm run api:gen)时方法不存在,降级为友好报错;
// groupId 为行所在组(每行是「用户×组」对),所属部门只画该组链路而非用户全部归属组
const userGroupTreeApi = (Apis.GroupAuthz as any).userGroupTree const userGroupTreeApi = (Apis.GroupAuthz as any).userGroupTree
? Apis.GroupAuthz.userGroupTree({ params: { userId }, cacheFor: null }) ? Apis.GroupAuthz.userGroupTree({ params: { userId, groupId: groupId ?? undefined }, cacheFor: null })
: Promise.reject(new Error("所属部门接口未生成,请重启后端后执行 npm run api:gen")); : Promise.reject(new Error("所属部门接口未生成,请重启后端后执行 npm run api:gen"));
const [permRes, deptRes, roleRes] = await Promise.allSettled([ const [permRes, deptRes, roleRes] = await Promise.allSettled([
Apis.GroupAuthz.userPermissionTree({ Apis.GroupAuthz.userPermissionTree({
@@ -251,11 +287,16 @@ async function onSelectUser(user: UserItem) {
Apis.GroupAuthz.userRoles({ params: { userId }, cacheFor: null }), Apis.GroupAuthz.userRoles({ params: { userId }, cacheFor: null }),
]); ]);
if (permRes.status === "fulfilled") { if (permRes.status === "fulfilled") {
// 加载期间用户可能已点了部门/角色节点切走视图,仅在仍处于该用户视图时回填
if (permScope.value.type === "user" && permScope.value.id === userId) {
permissionTree.value = formatPermissionTree((permRes.value as CategoryNode[]) || []); permissionTree.value = formatPermissionTree((permRes.value as CategoryNode[]) || []);
}
} else { } else {
console.warn(permRes.reason?.message || "加载权限树失败"); console.warn(permRes.reason?.message || "加载权限树失败");
if (permScope.value.type === "user" && permScope.value.id === userId) {
permissionTree.value = []; permissionTree.value = [];
} }
}
if (deptRes.status === "fulfilled") { if (deptRes.status === "fulfilled") {
deptTree.value = formatDeptTree((deptRes.value as GroupNode[]) || []); deptTree.value = formatDeptTree((deptRes.value as GroupNode[]) || []);
} else { } else {
@@ -271,15 +312,122 @@ async function onSelectUser(user: UserItem) {
treeLoading.value = false; treeLoading.value = false;
} }
// 所属部门树节点点击:右列切换为「该组的组授权」视图(开通池=本组+祖先并集、就近继承;不含任何角色授权)
function onDeptNodeClick(node: any) {
const id = String(node?.id ?? "");
if (!id.startsWith("grp_")) return;
const groupId = Number(id.slice(4));
if (!groupId || (permScope.value.type === "group" && permScope.value.id === groupId)) return;
permScope.value = { type: "group", id: groupId, label: String(node.name || "") };
permLoading.value = true;
Apis.GroupAuthz.groupPermissionTree({ params: { groupId }, cacheFor: null })
.then((cats: any) => {
if (permScope.value.type === "group" && permScope.value.id === groupId) {
permissionTree.value = formatPermissionTree((cats as CategoryNode[]) || []);
}
})
.catch((e: any) => {
console.warn(e?.message || "加载组权限树失败");
if (permScope.value.type === "group" && permScope.value.id === groupId) {
permissionTree.value = [];
}
})
.finally(() => {
permLoading.value = false;
});
}
// 所属角色树节点点击:右列切换为「该角色的授权模块」视图。
// 复用角色授权面板的 RoleModuleTree 回显接口,仅保留勾选(checked)模块,无勾选模块的应用整棵不显示。
function onRoleNodeClick(node: any) {
const id = String(node?.id ?? "");
if (!id.startsWith("role_")) return; // 「平台角色」/应用名分组节点不响应
const roleId = Number(id.slice(5));
if (!roleId || (permScope.value.type === "role" && permScope.value.id === roleId)) return;
permScope.value = { type: "role", id: roleId, label: String(node.name || "") };
permLoading.value = true;
Promise.all([
Apis.AppRole.roleModuleTree({ params: { roleId }, cacheFor: null }),
Apis.AppSettings.appList({ params: { pageIndex: 1, pageSize: 9999 }, cacheFor: null }),
Apis.AppSettings.appTypeList({ cacheFor: null }),
])
.then(([apps, appListRes, typeRes]: any[]) => {
if (permScope.value.type === "role" && permScope.value.id === roleId) {
permissionTree.value = formatRoleAuthzTree(apps, appListRes, typeRes);
}
})
.catch((e: any) => {
console.warn(e?.message || "加载角色权限树失败");
if (permScope.value.type === "role" && permScope.value.id === roleId) {
permissionTree.value = [];
}
})
.finally(() => {
permLoading.value = false;
});
}
// 角色授权模块 → 按应用分类分组(应用无 categoryId 时落「未分类」,与角色授权面板同口径)
function formatRoleAuthzTree(apps: any[], appListRes: any, typeRes: any): any[] {
const appArr: any[] = appListRes?.content ?? appListRes ?? [];
const catMap = new Map<number, number>();
appArr.forEach((a: any) => {
if (a.id != null) catMap.set(Number(a.id), Number(a.categoryId));
});
const typeMap = new Map<number, string>();
(typeRes ?? []).forEach((t: any) => typeMap.set(Number(t.id), String(t.name || `分类${t.id}`)));
const groups = new Map<number, any>();
for (const app of apps || []) {
const children = filterCheckedModules(app.modules || []);
if (children.length === 0) continue; // 角色未勾选任何模块的应用不展示
const cid = catMap.get(Number(app.id)) ?? -1;
if (!groups.has(cid)) {
groups.set(cid, { id: cid, name: typeMap.get(cid) || "未分类", open: true, isParent: true, children: [] });
}
groups.get(cid).children.push({
id: `app_${app.id}_${cid}`,
name: app.name || `应用${app.id}`,
open: false, // 与用户视图一致:应用一级默认闭合,点击才展开模块
children,
});
}
// 分类按 id 升序,「未分类」(-1) 沉底;应用沿用接口返回顺序(后端已按 sortNo 排好)
return [...groups.values()].sort((a, b) => {
if (a.id === -1) return 1;
if (b.id === -1) return -1;
return a.id - b.id;
});
}
// zTree 的父节点判定是「children 是数组即 isParent=true」——空数组也会渲染出 +/− 展开图标,
// 因此统一约定:有子节点才挂 children 键,否则不挂(与组用户管理左侧树的构造口径一致)
function treeNode(base: Record<string, any>, kids: any[]): any {
const node: any = { ...base, isParent: kids.length > 0 };
if (kids.length > 0) node.children = kids;
return node;
}
// 仅保留勾选(checked)的模块;父未勾选但子孙有勾选时保留父节点以维持层级
function filterCheckedModules(nodes: any[]): any[] {
const out: any[] = [];
for (const n of nodes || []) {
const kids = filterCheckedModules(n.children || []);
if (n.checked || kids.length > 0) {
out.push(treeNode({ id: `mod_${n.id}`, name: n.name || `模块${n.id}`, open: true }, kids));
}
}
return out;
}
// 所属部门:祖先链树,用户直接归属的组加「本组」标记 // 所属部门:祖先链树,用户直接归属的组加「本组」标记
function formatDeptTree(nodes: GroupNode[]): any[] { function formatDeptTree(nodes: GroupNode[]): any[] {
return (nodes || []).map((n) => ({ return (nodes || []).map((n) =>
id: `grp_${n.id}`, treeNode(
name: n.own ? `${n.name || ""}(本组)` : n.name || "", { id: `grp_${n.id}`, name: n.own ? `${n.name || ""}(本组)` : n.name || "", open: true },
open: true, formatDeptTree(n.children || [])
children: formatDeptTree(n.children || []), )
isParent: (n.children || []).length > 0, );
}));
} }
// 所属角色:2 级树 —— 一级为「平台角色」(全应用) / 应用名分组,二级为角色 // 所属角色:2 级树 —— 一级为「平台角色」(全应用) / 应用名分组,二级为角色
@@ -302,40 +450,30 @@ function formatRoleTree(roles: RoleItem[]): any[] {
} }
function formatPermissionTree(categories: CategoryNode[]): any[] { function formatPermissionTree(categories: CategoryNode[]): any[] {
const result: any[] = []; return (categories || []).map((cat) =>
for (const cat of categories) { treeNode(
const catNode: any = { { id: `cat_${cat.id}`, name: cat.name || `应用类型${cat.id}`, open: true },
id: `cat_${cat.id}`, formatApps(cat.apps || [], cat.id)
name: cat.name || `应用类型${cat.id}`, )
open: true, );
children: formatApps(cat.apps || [], cat.id),
};
catNode.isParent = catNode.children.length > 0;
result.push(catNode);
}
return result;
} }
function formatApps(apps: AppNode[], catId?: number): any[] { function formatApps(apps: AppNode[], catId?: number): any[] {
return (apps || []).map((app) => ({ return (apps || []).map((app) =>
id: `app_${app.id}_${catId || 0}`, treeNode(
name: app.name || `应用${app.id}`, { id: `app_${app.id}_${catId || 0}`, name: app.name || `应用${app.id}`, open: false }, // 应用一级默认闭合,点击才展开模块
open: true, formatModules(app.modules || [])
children: formatModules(app.modules || []), )
})); );
} }
function formatModules(modules: ModuleNode[]): any[] { function formatModules(modules: ModuleNode[]): any[] {
return (modules || []).map((m) => { return (modules || []).map((m) =>
const children = formatModules(m.children || []); treeNode(
return { { id: `mod_${m.id}`, name: m.name || `模块${m.id}`, open: true },
id: `mod_${m.id}`, formatModules(m.children || [])
name: m.name || `模块${m.id}`, )
open: true, );
children,
isParent: children.length > 0,
};
});
} }
</script> </script>
@@ -444,6 +582,30 @@ function formatModules(modules: ModuleNode[]): any[] {
font-size: 13px; font-size: 13px;
background: #fafafa; background: #fafafa;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
}
.side-title-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.expand-all {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 4px;
font-weight: 400;
}
.expand-all-label {
font-size: 12px;
color: #999;
white-space: nowrap;
} }
.side-body { .side-body {
@@ -499,19 +661,29 @@ function formatModules(modules: ModuleNode[]): any[] {
color: #2d8cf0; color: #2d8cf0;
} }
:deep(.group-tree-search-panel) {
height: 100%;
}
:deep(.ztree) { :deep(.ztree) {
font-size: 14px; font-size: 14px;
li {
line-height: 26px;
}
a { a {
color: #333; color: #333;
} }
} }
</style> </style>
<style lang="scss">
/* 左列对齐「组用户管理」页的写法(不覆盖全局 .tree-container):
容器改 flex 纵向并收起自身滚动,把滚动条收敛到树节点区域,避免整列滚动把搜索框滚走、树尾被裁。
注意不要在此处覆盖 zTree 的行高:zTree 官方样式的图标/连线按 21px 节距排布,
把 li line-height 改大(如 26px)会让每行多出的空隙在连接线上露出断口。 */
.tree-wrapper.permission-view .tree-container.permission-tree {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
.group-tree-search-panel {
flex: 1;
min-height: 0;
}
}
</style>
+5 -1
View File
@@ -22,7 +22,7 @@ let onSuccessCallback: (() => void) | null = null;
// useForm 托管提交 // useForm 托管提交
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm( const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
(dto) => Apis.AppSettings.addApp({ data: dto }), (dto) => Apis.AppSettings.addApp({ data: dto }),
{ initialForm: { name: '', categoryId: undefined, desc: '', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true } { initialForm: { name: '', categoryId: undefined, desc: '', appCode: '', appNature: 'BUSINESS', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', iconKey: '', iconColor: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true }
); );
onSuccess(() => { onSuccess(() => {
@@ -50,11 +50,15 @@ async function handleSubmit(formData: Record<string, any>) {
name: formData.name, name: formData.name,
categoryId: formData.categoryId, categoryId: formData.categoryId,
desc: formData.desc, desc: formData.desc,
appCode: formData.appCode,
appNature: formData.appNature,
clients: formData.clients, clients: formData.clients,
pcUrl: formData.pcUrl, pcUrl: formData.pcUrl,
pcIcon: formData.pcIcon, pcIcon: formData.pcIcon,
appUrl: formData.appUrl, appUrl: formData.appUrl,
appIcon: formData.appIcon, appIcon: formData.appIcon,
iconKey: formData.iconKey,
iconColor: formData.iconColor,
officialAccountUrl: formData.officialAccountUrl, officialAccountUrl: formData.officialAccountUrl,
dingtalkUrl: formData.dingtalkUrl, dingtalkUrl: formData.dingtalkUrl,
}; };
+155 -15
View File
@@ -33,6 +33,31 @@
</Col> </Col>
</Row> </Row>
<!-- 应用性质 / 技术标识(学校模式隐藏——性质与技术标识均为平台主数据) -->
<Row v-if="!schoolMode">
<Col span="12">
<FormItem label="应用性质:">
<Switch v-model="natureBase" size="small" />
<span class="nature-hint">{{ natureBase ? '基石应用' : '业务应用' }}</span>
<Tooltip :max-width="320" content="基石应用不参与应用开通 / 授权 / 模块管理,不出现在门户应用列表与跳转注册表" placement="top" transfer>
<Icon type="md-help" class="help-icon" />
</Tooltip>
</FormItem>
</Col>
<Col span="12">
<FormItem label="技术标识:" prop="appCode">
<div class="secret-row">
<Input v-model="form.appCode" placeholder="如 flow-center(= 部署名,创建后尽量不改)" style="flex:1" />
<Tooltip :max-width="320"
content="技术标识(app_code):kebab-case,小写字母开头,仅小写字母/数字/连字符,≤32 位;= projman 部署名(容器名/网关 ctx/服务目录键),创建后尽量不改。"
placement="top" transfer>
<Button size="small" class="ml-8" icon="md-help" />
</Tooltip>
</div>
</FormItem>
</Col>
</Row>
<!-- 第三行:appKey / appSecret(编辑模式且非学校模式才显示) --> <!-- 第三行:appKey / appSecret(编辑模式且非学校模式才显示) -->
<Row v-if="isEdit && !schoolMode"> <Row v-if="isEdit && !schoolMode">
<Col span="12"> <Col span="12">
@@ -50,8 +75,8 @@
</Col> </Col>
</Row> </Row>
<!-- 第四行:应用前端类型(tag 多选;学校模式隐藏) --> <!-- 第四行:应用前端类型(tag 多选;学校模式/基石应用隐藏——基石不进门户,无投放载体) -->
<Row v-if="!schoolMode"> <Row v-if="!schoolMode && !natureBase">
<Col span="24"> <Col span="24">
<FormItem label="应用前端类型:"> <FormItem label="应用前端类型:">
<Select v-model="form.clients" multiple transfer placeholder="请选择前端类型" class="client-select"> <Select v-model="form.clients" multiple transfer placeholder="请选择前端类型" class="client-select">
@@ -61,14 +86,14 @@
</Col> </Col>
</Row> </Row>
<!-- 第五行:Pc应用地址 / Pc应用图标(学校模式仅保留 Pc应用图标) --> <!-- 第五行:Pc应用地址 / 应用图标(平台模式=AppIcon 矢量选择器;学校模式保留位图上传——学校覆盖线只收 pcIcon) -->
<Row> <Row v-if="!natureBase">
<Col span="12" v-if="!schoolMode"> <Col span="12" v-if="!schoolMode">
<FormItem label="Pc应用地址:"> <FormItem label="Pc应用地址:">
<Input v-model="form.pcUrl" placeholder="请输入Pc应用地址" /> <Input v-model="form.pcUrl" placeholder="请输入Pc应用地址" />
</FormItem> </FormItem>
</Col> </Col>
<Col span="12"> <Col span="12" v-if="schoolMode">
<FormItem label="Pc应用图标:"> <FormItem label="Pc应用图标:">
<Upload :on-success="(res: any) => onUploadSuccess(res, 'pcIcon')" :show-upload-list="false" <Upload :on-success="(res: any) => onUploadSuccess(res, 'pcIcon')" :show-upload-list="false"
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg" :action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
@@ -84,35 +109,73 @@
</Upload> </Upload>
</FormItem> </FormItem>
</Col> </Col>
<Col span="12" v-else>
<FormItem label="应用图标:">
<AppIconPicker v-model="form.iconKey" v-model:color="form.iconColor" />
</FormItem>
</Col>
</Row> </Row>
<!-- 第六行:App接口地址 / App应用图标(学校模式隐藏) --> <!-- 第六行:App接口地址(学校模式/基石应用隐藏;App位图图标移入下方高级折叠区) -->
<Row v-if="!schoolMode"> <Row v-if="!schoolMode && !natureBase">
<Col span="12"> <Col span="12">
<FormItem label="App接口地址:"> <FormItem label="App接口地址:">
<Input v-model="form.appUrl" placeholder="请输入App接口地址" /> <Input v-model="form.appUrl" placeholder="请输入App接口地址" />
</FormItem> </FormItem>
</Col> </Col>
<Col span="12"> </Row>
<FormItem label="App应用图标:">
<Upload :on-success="(res: any) => onUploadSuccess(res, 'appIcon')" :show-upload-list="false" <!-- 位图上传(高级 · 遗留通道):存量 pc_icon/app_icon 位图链路保留,新应用优先矢量图标键 -->
<Row v-if="!schoolMode && !natureBase">
<Col span="24">
<Collapse>
<Panel name="bitmap">
上传位图图标(高级 · 遗留通道)
<template #content>
<div class="bitmap-upload-row">
<div class="bitmap-upload-item">
<div class="bitmap-label">Pc应用图标:</div>
<Upload :on-success="(res: any) => onUploadSuccess(res, 'pcIcon')"
:before-upload="beforeBitmapUpload" :show-upload-list="false"
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
:with-credentials="true" :headers="{ yschapirequest: 1 }">
<div v-if="!form.pcIcon" class="icon-upload-wrap">
<SvgIcon name="upload-add" color="#646566" style="width: 20px; height: 20px" />
</div>
<div class="icon-preview-wrap" v-else>
<SvgIcon @click.stop="form.pcIcon = null" class="cloase-btn"
name="widget-close" size="18" color="#fff" />
<img :src="previewUrl(form.pcIcon)" alt="" />
</div>
</Upload>
<div class="bitmap-hint">矢量图标(上方)优先生效;此位图为存量回退。</div>
</div>
<div class="bitmap-upload-item">
<div class="bitmap-label">App应用图标:</div>
<Upload :on-success="(res: any) => onUploadSuccess(res, 'appIcon')"
:before-upload="beforeBitmapUpload" :show-upload-list="false"
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg" :action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
:with-credentials="true" :headers="{ yschapirequest: 1 }"> :with-credentials="true" :headers="{ yschapirequest: 1 }">
<div v-if="!form.appIcon" class="icon-upload-wrap"> <div v-if="!form.appIcon" class="icon-upload-wrap">
<SvgIcon name="upload-add" color="#646566" style="width: 20px; height: 20px" /> <SvgIcon name="upload-add" color="#646566" style="width: 20px; height: 20px" />
</div> </div>
<div class="icon-preview-wrap" v-else> <div class="icon-preview-wrap" v-else>
<SvgIcon @click.stop="form.appIcon = null" class="cloase-btn" name="widget-close" size="18" <SvgIcon @click.stop="form.appIcon = null" class="cloase-btn"
color="#fff" /> name="widget-close" size="18" color="#fff" />
<img :src="previewUrl(form.appIcon)" alt="" /> <img :src="previewUrl(form.appIcon)" alt="" />
</div> </div>
</Upload> </Upload>
</FormItem> <div class="bitmap-hint">存量移动端位图;矢量图标优先生效。</div>
</div>
</div>
</template>
</Panel>
</Collapse>
</Col> </Col>
</Row> </Row>
<!-- 第七行:公众号应用地址 / 钉钉应用地址(学校模式隐藏) --> <!-- 第七行:公众号应用地址 / 钉钉应用地址(学校模式/基石应用隐藏) -->
<Row v-if="!schoolMode"> <Row v-if="!schoolMode && !natureBase">
<Col span="12"> <Col span="12">
<FormItem label="公众号应用地址:"> <FormItem label="公众号应用地址:">
<Input v-model="form.officialAccountUrl" placeholder="请输入公众号应用地址" /> <Input v-model="form.officialAccountUrl" placeholder="请输入公众号应用地址" />
@@ -135,6 +198,7 @@
<script setup lang="ts"> <script setup lang="ts">
import SvgIcon from "@/components/SvgIcon.vue"; import SvgIcon from "@/components/SvgIcon.vue";
import AppIconPicker from "@/components/AppIconPicker.vue";
import { computed, reactive, watch } from "vue"; import { computed, reactive, watch } from "vue";
import useCurrentInstance from "@/utils/hook/useCurrentInstance"; import useCurrentInstance from "@/utils/hook/useCurrentInstance";
@@ -173,11 +237,16 @@ const defaultForm = (): Record<string, any> => ({
desc: '', desc: '',
appKey: null, appKey: null,
appSecret: '', appSecret: '',
appCode: '',
appNature: 'BUSINESS',
clients: [] as number[], clients: [] as number[],
pcUrl: '', pcUrl: '',
pcIcon: '', pcIcon: '',
appUrl: '', appUrl: '',
appIcon: '', appIcon: '',
// 矢量图标(AppIcon 资产库):iconKey 空 = 不使用矢量,端侧走 关键词→位图→首字 原回退链
iconKey: '',
iconColor: '',
officialAccountUrl: '', officialAccountUrl: '',
dingtalkUrl: '', dingtalkUrl: '',
}); });
@@ -211,6 +280,17 @@ function onUploadSuccess(response: any, field: 'pcIcon' | 'appIcon') {
} }
} }
/* 位图上传前置校验:≤64KB(与后端 P4 校验同口径;svg 另由后端消毒) */
const BITMAP_MAX_BYTES = 64 * 1024;
function beforeBitmapUpload(file: File) {
if (file.size > BITMAP_MAX_BYTES) {
Message.error(`图标文件 ${(file.size / 1024).toFixed(0)}KB 超过 64KB 上限,请压缩后上传`);
return false;
}
return true;
}
/* ---------- 前端类型选项 ---------- */ /* ---------- 前端类型选项 ---------- */
// value 对齐后端平台枚举 com.yunhai.platform.utils.enums.ClientEnum(位掩码 key): // value 对齐后端平台枚举 com.yunhai.platform.utils.enums.ClientEnum(位掩码 key):
// 1=Pc浏览器 2=移动App 8=微信公众号 32=钉钉;公众号/钉钉地址见表单第七行 // 1=Pc浏览器 2=移动App 8=微信公众号 32=钉钉;公众号/钉钉地址见表单第七行
@@ -221,12 +301,26 @@ const clientOptions = [
{ value: 32, label: '钉钉' }, { value: 32, label: '钉钉' },
]; ];
/* ---------- 应用性质 Switch(开=BASE 基石 / 关=BUSINESS 业务) ---------- */
const natureBase = computed({
get: () => form.appNature === 'BASE',
set: (val: boolean) => { form.appNature = val ? 'BASE' : 'BUSINESS'; },
});
/* ---------- 校验规则(学校模式不校验 categoryId——类型字段不展示且不可改) ---------- */ /* ---------- 校验规则(学校模式不校验 categoryId——类型字段不展示且不可改) ---------- */
const rules = computed(() => ({ const rules = computed(() => ({
name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }], name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
...(props.schoolMode ? {} : { ...(props.schoolMode ? {} : {
categoryId: [{ required: true, type: 'number' as const, message: '请选择应用类型', trigger: 'change' }], categoryId: [{ required: true, type: 'number' as const, message: '请选择应用类型', trigger: 'change' }],
}), }),
// 技术标识新增与编辑均必填(2026-09-28 拍板,后端 Create/Update 组同口径);学校模式不展示该字段
...(props.schoolMode ? {} : {
appCode: [
{ required: true, message: '请输入技术标识', trigger: 'blur' },
// 2026-09-28 拍板命名规范:app_code 一律 kebab-case 且 = 部署名(PlatformService 枚举头注释同源)
{ pattern: /^[a-z][a-z0-9-]{0,31}$/, message: '小写字母开头,仅小写字母/数字/连字符,≤32 位', trigger: 'blur' },
],
}),
})); }));
/* ---------- 提交 ---------- */ /* ---------- 提交 ---------- */
@@ -348,4 +442,50 @@ function onVisibleChange(val: boolean) {
overflow-y: auto; overflow-y: auto;
} }
} }
/* 位图上传(高级折叠区):并排两枚上传位 */
.bitmap-upload-row {
display: flex;
gap: 32px;
padding: 8px 4px;
}
.bitmap-upload-item {
.bitmap-label {
font-size: 13px;
color: #515a6e;
margin-bottom: 8px;
}
.bitmap-hint {
margin-top: 6px;
font-size: 12px;
color: #999;
}
}
/* 应用性质说明文字 */
.nature-hint {
margin-left: 8px;
color: #808695;
font-size: 12px;
}
/* 表单帮助图标(Tooltip 触发器):静默态用中性灰阶令牌,交互态才升主色(含淡底),
一律不写死色号——切档位(blue/ocean/ink/violet/teal)自动跟随。
括号内兜底值 = 默认档 blue,仅防通道 CSS 不可达时图标失色(接入后由通道令牌接管)。 */
.help-icon {
margin-left: 6px;
padding: 1px;
border-radius: 50%;
color: var(--text-3, #98a1b2);
font-size: 16px;
cursor: pointer;
transition: color 0.15s, background-color 0.15s;
&:hover {
color: var(--brand, #577afb);
background-color: var(--brand-soft, #eef2ff);
}
}
</style> </style>
+54 -58
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 },
@@ -169,21 +164,46 @@ const columns = [
}, },
}, },
{ {
title: "操作", title: "性质",
key: "action", key: "appNature",
align: 'center', width: 80,
width: 280, render: (h: any, { row }: any) => h("span", row.appNature === "BASE" ? "基石" : "业务"),
render: (h: any, { row }: any) => {
// SCHOOL 模式:仅编辑(名称/描述/图标,写学校覆盖表);模块管理/推送设置/删除全部隐藏
const actions = [$utils.operatingTextHandle(h, '编辑', '', () => handleEdit(row))];
if (!isSchoolMode.value) {
actions.push($utils.operatingTextHandle(h, '模块管理', '', () => goModuleManage(row)));
actions.push($utils.operatingTextHandle(h, '推送设置', '', () => openPushSetting(row)));
actions.push($utils.operatingTextHandle(h, '删除', 'text-error', () => handleDelete(row)));
}
return actions;
}
}, },
actionColumn(
[
// SCHOOL 模式:仅编辑(名称/描述/图标,写学校覆盖表);模块管理/推送设置/删除全部隐藏。
// 基石应用(app_nature=BASE)只做身份注册:模块/推送置灰;删除允许——基石也可下线,
// 但走「输名确认」删除模(危险确认,与 projman 应用删除同款)。
{ key: "edit", label: "编辑", icon: "icon-edit", on: (r: any) => handleEdit(r) },
{
key: "module",
label: "模块管理",
icon: "icon-apps",
on: (r: any) => goModuleManage(r),
when: () => !isSchoolMode.value,
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 },
),
]; ];
// 条件列:仅在「未筛选应用类型」时插入到“应用名称”之后 // 条件列:仅在「未筛选应用类型」时插入到“应用名称”之后
@@ -231,18 +251,24 @@ function openSortModal() {
sortModalVisible.value = true; sortModalVisible.value = true;
} }
/* ---------------- 删除应用 ---------------- */ /* ---------------- 删除应用(删除模:输名确认 + 红色确认键,对齐 projman;基石应用同样可删) ---------------- */
async function handleDelete(row: any) { async function handleDelete(row: any) {
const name = row.name || String(row.id);
await MyConfirm({ await MyConfirm({
msg: `确定删除应用「${row.name || row.id}」吗?`, title: "删除应用",
info: "删除后不可恢复!", msg: `确定删除应用「${name}」吗?`,
info: "删除后不可恢复,关联就绪资源需手动清理!"
+ (row.appNature === "BASE" ? "(基石应用:请先确认无平台组件仍依赖其 appKey 签名身份)" : ""),
confirmName: name,
danger: true,
confirmText: "删除",
async onOk() { async onOk() {
try { try {
await Apis.AppSettings.deleteApp({ pathParams: { id: row.id } }); await Apis.AppSettings.deleteApp({ pathParams: { id: row.id } });
Message.success('删除成功!'); Message.success('删除成功!');
tableRef.value?.reloadData(OperatorEnum.del); tableRef.value?.reloadData(OperatorEnum.del);
} catch (e: any) { } catch (e: any) {
console.warn(e?.message || '删除失败'); Message.error(e?.message || '删除失败');
} }
}, },
}); });
@@ -273,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 {
@@ -295,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);
} }
} }
@@ -316,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>
+11 -2
View File
@@ -34,7 +34,7 @@ const { form, loading: submitting, send: sendForm, onSuccess, onError } = useFor
data: dto, data: dto,
params: props.schoolId != null ? { schoolId: props.schoolId } : undefined, params: props.schoolId != null ? { schoolId: props.schoolId } : undefined,
}), }),
{ initialForm: { id: null, name: '', categoryId: undefined, desc: '', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true } { initialForm: { id: null, name: '', categoryId: undefined, desc: '', appCode: '', appNature: 'BUSINESS', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', iconKey: '', iconColor: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true }
); );
onSuccess(() => { onSuccess(() => {
@@ -67,11 +67,16 @@ function open(id: number, row: any, categories: any[], onSuccess?: () => void) {
async function handleSubmit(formData: Record<string, any>) { async function handleSubmit(formData: Record<string, any>) {
if (!currentId.value) return; if (!currentId.value) return;
if (isSchoolMode) { if (isSchoolMode) {
// 学校模式:仅 名称/描述/Pc图标 三字段写学校覆盖表,其余字段一律不提交 // 学校模式:仅 名称/描述/Pc图标 三字段写学校覆盖表(后端 editAppForSchool 只读这三个);
// categoryId/appCode/appNature 原样回传仅为满足平台级 Update 校验(后端不落平台表),缺失会 400;
// iconKey/iconColor 不送键(后端 null=不改——学校线不动平台矢量配置)
form.value = { form.value = {
id: currentId.value, id: currentId.value,
name: formData.name, name: formData.name,
categoryId: formData.categoryId,
desc: formData.desc, desc: formData.desc,
appCode: formData.appCode,
appNature: formData.appNature,
pcIcon: formData.pcIcon, pcIcon: formData.pcIcon,
}; };
} else { } else {
@@ -80,11 +85,15 @@ async function handleSubmit(formData: Record<string, any>) {
name: formData.name, name: formData.name,
categoryId: formData.categoryId, categoryId: formData.categoryId,
desc: formData.desc, desc: formData.desc,
appCode: formData.appCode,
appNature: formData.appNature,
clients: formData.clients, clients: formData.clients,
pcUrl: formData.pcUrl, pcUrl: formData.pcUrl,
pcIcon: formData.pcIcon, pcIcon: formData.pcIcon,
appUrl: formData.appUrl, appUrl: formData.appUrl,
appIcon: formData.appIcon, appIcon: formData.appIcon,
iconKey: formData.iconKey,
iconColor: formData.iconColor,
officialAccountUrl: formData.officialAccountUrl, officialAccountUrl: formData.officialAccountUrl,
dingtalkUrl: formData.dingtalkUrl, dingtalkUrl: formData.dingtalkUrl,
}; };
+52 -27
View File
@@ -27,10 +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 label="模块图标:" prop="icon">
<Input placeholder="选填,YschIcons 图标名(如 notice-list);留空则不下发图标"
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" />
@@ -50,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();
@@ -91,25 +93,39 @@ const flatData = computed(() => flattenTree(moduleList.value));
/* ---------------- 表格列 ---------------- */ /* ---------------- 表格列 ---------------- */
const columns = [ const columns = [
{ title: "模块名称", key: "name", minWidth: 260, slot: "name" }, { title: "模块名称", key: "name", minWidth: 260, slot: "name" },
{
title: "图标",
key: "icon",
width: 170,
align: "center",
render: (h: any, { row }: any) => {
if (!row.icon) return h("span", { style: { color: "#c5c8ce" } }, "-");
return h("div", {
style: { display: "flex", alignItems: "center", justifyContent: "center", gap: "6px" },
title: row.icon,
}, [
h(SvgIcon, { name: row.icon, size: 16 }),
h("span", {
style: { maxWidth: "110px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
}, row.icon),
]);
},
},
{ 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
actionColumn([
{ key: "edit", label: "编辑", icon: "icon-edit", on: (r: any) => handleEdit(r) },
{ {
title: "操作", key: "add",
key: "action", label: "添加子模块",
width: 220, icon: "icon-tianjia",
align: "center", on: (r: any) => handleAddChild(r),
render: (h: any, { row }: any) => { when: (r: any) => r.level < 5,
const actions: any[] = [
$utils.operatingTextHandle(h, '编辑', '', () => handleEdit(row)),
$utils.operatingTextHandle(h, '删除', 'text-error', () => handleDelete(row)),
];
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 }),
]; ];
/* ---------------- 弹窗状态 ---------------- */ /* ---------------- 弹窗状态 ---------------- */
@@ -123,6 +139,7 @@ const form = ref<{
id: number | null; id: number | null;
name: string; name: string;
path: string; path: string;
icon: string;
customId: number | null; customId: number | null;
parentId: number | null; parentId: number | null;
sortNo: number | null; sortNo: number | null;
@@ -130,18 +147,22 @@ const form = ref<{
id: null, id: null,
name: '', name: '',
path: '', path: '',
icon: '',
customId: null, customId: null,
parentId: null, parentId: null,
sortNo: null, sortNo: null,
}); });
const ruleValidate = ref({ const openingIsChild = ref(false);
/* 子模块双定位:树节点(带路径/编码)或纯按钮权限(两者皆可空)→ 仅根模块必填 */
const ruleValidate = computed(() => ({
name: [{ required: true, message: "请输入模块名称", trigger: "blur" }], name: [{ required: true, message: "请输入模块名称", trigger: "blur" }],
...(openingIsChild.value ? {} : {
path: [{ required: true, message: "请输入模块路径", trigger: "blur" }], path: [{ required: true, message: "请输入模块路径", trigger: "blur" }],
customId: [{ required: true, type: "number", message: "请输入自定义ID", trigger: "change" }], customId: [{ required: true, type: "number", message: "请输入自定义ID", trigger: "change" }],
}); }),
}));
const openingIsChild = ref(false);
const modalTitle = computed(() => { const modalTitle = computed(() => {
if (currentId.value != null) return '编辑模块'; if (currentId.value != null) return '编辑模块';
return openingIsChild.value ? '新增子模块' : '新增模块'; return openingIsChild.value ? '新增子模块' : '新增模块';
@@ -177,6 +198,7 @@ function openEditModal(item: any) {
f.id = item?.id ?? null; f.id = item?.id ?? null;
f.name = item?.name || ''; f.name = item?.name || '';
f.path = item?.path || ''; f.path = item?.path || '';
f.icon = item?.icon || '';
f.customId = item?.customId != null ? Number(item.customId) : null; f.customId = item?.customId != null ? Number(item.customId) : null;
f.parentId = item?.parentId ?? null; f.parentId = item?.parentId ?? null;
f.sortNo = item?.sortNo != null ? Number(item.sortNo) : null; f.sortNo = item?.sortNo != null ? Number(item.sortNo) : null;
@@ -189,13 +211,13 @@ function openEditModal(item: any) {
/* ---------------- 新增模块 ---------------- */ /* ---------------- 新增模块 ---------------- */
function handleAdd() { function handleAdd() {
openingIsChild.value = false; openingIsChild.value = false;
openEditModal({ id: null, name: '', path: '', customId: null, parentId: null, sortNo: null }); openEditModal({ id: null, name: '', path: '', icon: '', customId: null, parentId: null, sortNo: null });
} }
/* ---------------- 新增子模块 ---------------- */ /* ---------------- 新增子模块 ---------------- */
function handleAddChild(parent: any) { function handleAddChild(parent: any) {
openingIsChild.value = true; openingIsChild.value = true;
openEditModal({ id: null, name: '', path: '', customId: null, parentId: parent.id, sortNo: null }); openEditModal({ id: null, name: '', path: '', icon: '', customId: null, parentId: parent.id, sortNo: null });
} }
/* ---------------- 编辑 ---------------- */ /* ---------------- 编辑 ---------------- */
@@ -239,7 +261,8 @@ 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,
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,
sortNo: form.value.sortNo == null ? undefined : Number(form.value.sortNo), sortNo: form.value.sortNo == null ? undefined : Number(form.value.sortNo),
@@ -252,7 +275,9 @@ 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 逐字段整体赋值,字段缺省即置空 → 清空图标/路径能生效
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,
sortNo: form.value.sortNo == null ? undefined : Number(form.value.sortNo), sortNo: form.value.sortNo == null ? undefined : Number(form.value.sortNo),
+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;
} }
} }
+40 -45
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="page-wrapper tree-wrapper group-user-page"> <div class="page-wrapper tree-wrapper group-user-page">
<Group class="tree-container" v-model:curTreeNode="selectedNode" :treeApi="treeApi" @on-change="onTreeChange" <Group class="tree-container group-user-tree" v-model:curTreeNode="selectedNode" :treeApi="treeApi"
@on-tree-data="onTreeData" /> @on-change="onTreeChange" @on-tree-data="onTreeData" />
<div class="main-container"> <div class="main-container">
<div class="search-wrap"> <div class="search-wrap">
<Button type="primary" @click="showAddUser"> <Button type="primary" @click="showAddUser">
@@ -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();
@@ -110,8 +111,8 @@ const groupOptions = computed(() => {
const flat: { id: number; name: string }[] = []; const flat: { id: number; name: string }[] = [];
const walk = (list: any[], prefix = '') => { const walk = (list: any[], prefix = '') => {
list.forEach(n => { list.forEach(n => {
flat.push({ id: n.id, name: prefix ? `${prefix} / ${n.name}` : n.name }); flat.push({ id: n.id, name: prefix ? `${prefix}/${n.name}` : n.name });
if (n.children?.length) walk(n.children, prefix ? `${prefix} / ${n.name}` : n.name); if (n.children?.length) walk(n.children, prefix ? `${prefix}/${n.name}` : n.name);
}); });
}; };
walk(treeData.value); walk(treeData.value);
@@ -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' },
@@ -157,55 +156,37 @@ const columns = [
{ title: '手机号', key: 'phone', minWidth: 140 }, { title: '手机号', key: 'phone', minWidth: 140 },
{ {
title: '用户身份', key: 'identityNames', minWidth: 180, title: '用户身份', key: 'identityNames', minWidth: 180,
// 数据源:后端按页批量带出的主校身份名(is_main 行,roles 列已停写,不再直读旧列) // 数据源:后端按页批量带出的身份名,且只取「当前所选组范围解析出的学校」下的身份行
// (与编辑弹窗「所在组」回显同口径,roles 列已停写不再直读旧列)
render: (h: any, { row }: any) => h('span', row.identityNames || '-'), render: (h: any, { row }: any) => h('span', row.identityNames || '-'),
}, },
{ {
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);
},
},
]; ];
/* ================= 导出组用户 ================= */ /* ================= 导出组用户 ================= */
@@ -367,8 +348,22 @@ function onSortSaved() {
</script> </script>
<style lang="scss"> <style lang="scss">
.group-user-page .page-panel { /* 左侧树容器改为 flex 纵向布局并收起自身滚动,把滚动条收敛到树节点区域
left: 348px !important; (滚动体是 MyTree 内部的 .ztree,见 components/MyTree.vue),
避免树过长时把顶部 opera-ul 操作按钮条滚出屏幕;仅作用于本页,不影响全局 .tree-container */
.tree-wrapper.group-user-page .tree-container.group-user-tree {
display: flex;
flex-direction: column;
overflow: hidden;
.opera-ul {
flex-shrink: 0;
}
.group-tree-search-panel {
flex: 1;
min-height: 0;
}
} }
.batch-status-body { .batch-status-body {
@@ -68,10 +68,12 @@ async function handleSubmit(formData: Record<string, any>) {
// 新增失败(onError 已提示),不继续绑定角色 // 新增失败(onError 已提示),不继续绑定角色
return; return;
} }
// 新增成功后再绑定业务角色 // 新增成功后再绑定业务角色(组维度:绑定挂在创建分组上,即左侧当前选中组)
if (newId != null && appRolesToSave.length) { if (newId != null && appRolesToSave.length) {
try { try {
await Apis.AppRole.saveUserRoles({ data: { userId: newId, roleIds: appRolesToSave } }); await Apis.AppRole.saveUserRoles({
data: { userId: newId, groupId: currentGroupId.value ?? undefined, roleIds: appRolesToSave },
});
} catch (e: any) { } catch (e: any) {
// 全局拦截器已弹窗,这里仅记录日志避免二次弹窗 // 全局拦截器已弹窗,这里仅记录日志避免二次弹窗
console.warn('[AddUserContainer] 用户已创建,但角色属性保存失败:', e?.message || '未知错误'); console.warn('[AddUserContainer] 用户已创建,但角色属性保存失败:', e?.message || '未知错误');
+18 -25
View File
@@ -20,10 +20,8 @@ const userGroups = ref<{ id: number; name: string }[]>([]);
const userGroupIdsArr = ref<number[]>([]); const userGroupIdsArr = ref<number[]>([]);
// 角色属性下拉:来自角色授权页的全量角色 // 角色属性下拉:来自角色授权页的全量角色
const roleOptions = ref<{ id: number; name: string }[]>([]); const roleOptions = ref<{ id: number; name: string }[]>([]);
// 角色属性是否成功加载(控制编辑后是否回写 saveUserRoles,避免加载失败时误清空) // 角色属性是否成功加载(控制编辑后是否回写 saveUserRoles,避免下拉失败时误清空)
const roleOptionsLoaded = ref(false); const roleOptionsLoaded = ref(false);
// 已绑定角色是否成功加载(与 roleOptionsLoaded 同时为真才回写,防止误清空)
const userRolesLoaded = ref(false);
// 待保存的角色属性(用户多选结果) // 待保存的角色属性(用户多选结果)
let appRolesToSave: number[] = []; let appRolesToSave: number[] = [];
@@ -62,7 +60,6 @@ async function open(row: any, groups: any[], curGroupId?: number | null, onSucce
const groupList = groups || []; const groupList = groups || [];
onSuccessCallback = onSuccess || null; onSuccessCallback = onSuccess || null;
roleOptionsLoaded.value = false; roleOptionsLoaded.value = false;
userRolesLoaded.value = false;
userGroups.value = []; userGroups.value = [];
userGroupIdsArr.value = []; userGroupIdsArr.value = [];
roleOptions.value = []; roleOptions.value = [];
@@ -104,28 +101,17 @@ async function open(row: any, groups: any[], curGroupId?: number | null, onSucce
console.warn('[EditUserContainer] 加载角色列表失败:', e?.message || '未知错误'); console.warn('[EditUserContainer] 加载角色列表失败:', e?.message || '未知错误');
} }
// 3) 已绑定角色 -> appRoles 回填(相互独立) // 3) 角色属性回显不再在此加载:UserForm 内部监听 userGroupId(初始选中即触发),
// 所在组(只读)默认选中「当前组」:优先用进入编辑时的左侧树/列表组(curGroupId),否则取用户所属组的第一个 // 按组调 GET UserRoles 回显该组角色,并对「该组回显未就绪」拦截提交,防止误清空。
// 所在组默认选中「当前组」:优先用进入编辑时的左侧树/列表组(curGroupId),否则取用户所属组的第一个
const defaultGroupId = (curGroupId != null && userGroupIdsArr.value.includes(curGroupId)) const defaultGroupId = (curGroupId != null && userGroupIdsArr.value.includes(curGroupId))
? curGroupId ? curGroupId
: (userGroupIdsArr.value[0] ?? null); : (userGroupIdsArr.value[0] ?? null);
try { detailData.value = buildInitForm(row, defaultGroupId);
const userRolesResp = await Apis.GroupAuthz.userRoles({ params: { userId: row.id } });
const userRoleIds: number[] = (Array.isArray(userRolesResp) ? userRolesResp : [])
.map((r: any) => r.id)
.filter((x: any): x is number => x != null);
userRolesLoaded.value = true;
detailData.value = buildInitForm(row, userRoleIds, defaultGroupId);
} catch (e: any) {
// 已绑定角色加载失败:仍回填其它字段,但标记未加载成功(提交时不回写,避免误清空)
detailData.value = buildInitForm(row, [], defaultGroupId);
// 全局拦截器已弹窗,这里仅记录日志避免二次弹窗
console.warn('[EditUserContainer] 加载用户已绑定角色失败:', e?.message || '未知错误');
}
} }
/* ---------- 把行数据映射成表单回填数据 ---------- */ /* ---------- 把行数据映射成表单回填数据 ---------- */
function buildInitForm(row: any, userRoleIds: number[], defaultGroupId: number | null = null) { function buildInitForm(row: any, defaultGroupId: number | null = null) {
return { return {
id: row.id, id: row.id,
account: row.account || '', account: row.account || '',
@@ -135,10 +121,10 @@ function buildInitForm(row: any, userRoleIds: number[], defaultGroupId: number |
password: '', password: '',
confirmPassword: '', confirmPassword: '',
gender: row.gender ?? 0, gender: row.gender ?? 0,
// 用户身份回显唯一来源是 GET UserIdentity(UserForm 内部按「所在组」加载): // 用户身份/角色属性回显唯一来源是 GET UserIdentity / GET UserRoles(UserForm 内部按「所在组」加载):
// roles 列已停写,row.roles 为冻结历史值,误用回显会把废弃值固化进权威表 // roles 列已停写,row.roles 为冻结历史值,误用回显会把废弃值固化进权威表
userType: [], userType: [],
appRoles: userRoleIds || [], appRoles: [],
userGroupId: defaultGroupId ?? null, userGroupId: defaultGroupId ?? null,
}; };
} }
@@ -154,10 +140,17 @@ async function handleSubmit(formData: Record<string, any>) {
// 编辑失败(onError 已提示),不继续回写角色 // 编辑失败(onError 已提示),不继续回写角色
return; return;
} }
// 编辑成功后再回写业务角色(全量覆盖);仅当角色列表与已绑定角色都成功加载才回写,防止误清空 // 编辑成功后再回写业务角色(按组覆盖:只覆盖当前所在组下的绑定,不影响其他组);
if (roleOptionsLoaded.value && userRolesLoaded.value) { // 仅当角色下拉成功加载才回写,防止误清空(该组角色回显未就绪时 UserForm 已拦截提交)
if (roleOptionsLoaded.value) {
try { try {
await Apis.AppRole.saveUserRoles({ data: { userId: currentId.value as number, roleIds: appRolesToSave } }); await Apis.AppRole.saveUserRoles({
data: {
userId: currentId.value as number,
groupId: (formData.userGroupId as number | null) ?? undefined,
roleIds: appRolesToSave,
},
});
} catch (e: any) { } catch (e: any) {
// 全局拦截器已弹窗,这里仅记录日志避免二次弹窗 // 全局拦截器已弹窗,这里仅记录日志避免二次弹窗
console.warn('[EditUserContainer] 用户已更新,但角色属性保存失败:', e?.message || '未知错误'); console.warn('[EditUserContainer] 用户已更新,但角色属性保存失败:', e?.message || '未知错误');
+31 -5
View File
@@ -57,12 +57,12 @@
</Col> </Col>
</Row> </Row>
<!-- 第 5 行:所在组(仅编辑展示;切换只触发下方「用户身份」回显,不改变实际所在组) --> <!-- 第 5 行:所在组(仅编辑展示;切换只触发下方「用户身份/角色属性」回显,不改变实际所在组) -->
<Row v-if="isEdit"> <Row v-if="isEdit">
<Col span="24"> <Col span="24">
<FormItem label="所在组:"> <FormItem label="所在组:">
<Select v-model="form.userGroupId" transfer clearable <Select v-model="form.userGroupId" transfer clearable
placeholder="切换后回显该组身份(不改变实际所在组)"> placeholder="切换后回显该组的身份与角色属性(不改变实际所在组)">
<Option v-for="g in userGroups" :key="g.id" :value="g.id" :label="g.name" /> <Option v-for="g in userGroups" :key="g.id" :value="g.id" :label="g.name" />
</Select> </Select>
</FormItem> </FormItem>
@@ -155,6 +155,9 @@ const form = reactive(defaultForm());
// 编辑模式回显到的最新的身份基线(用于「非空清成空」二次确认;随所在组切换同步更新) // 编辑模式回显到的最新的身份基线(用于「非空清成空」二次确认;随所在组切换同步更新)
let baselineUserType: number[] = []; let baselineUserType: number[] = [];
// 编辑模式:当前所在组的角色属性是否已回显成功(未就绪时拦截提交,避免把上一组的角色写到新组)
let appRolesEchoed = false;
// 外部 initForm 变化时回填 // 外部 initForm 变化时回填
watch(() => props.initForm, (val) => { watch(() => props.initForm, (val) => {
if (val && Object.keys(val).length > 0) { if (val && Object.keys(val).length > 0) {
@@ -185,11 +188,19 @@ const userTypeOptions = computed(() => {
return SYS_ROLE_FALLBACK; return SYS_ROLE_FALLBACK;
}); });
/* ---------- 用户身份回显(唯一来源 GET UserIdentity) ---------- */ /* ---------- 所在组切换:身份 + 角色属性 双回显(唯一来源 GET UserIdentity / GET UserRoles) ---------- */
// 所在组 Select 切换(含弹窗打开时的初始选中)→ 后端解析 schoolId 后回显 {schoolId, roleIds}。 // 所在组 Select 切换(含弹窗打开时的初始选中)→ 后端解析 schoolId 后回显 {schoolId, roleIds};
// 角色属性为组维度(一行 = 用户×组×角色),同步按所选组回显该组下的角色,互不影响其他组绑定。
// 同一学校多组由后端返回同一 schoolId 与角色并集;前端不感知解析逻辑。 // 同一学校多组由后端返回同一 schoolId 与角色并集;前端不感知解析逻辑。
watch(() => form.userGroupId, (gid: number | null) => { watch(() => form.userGroupId, (gid: number | null) => {
if (!props.isEdit || gid == null || !form.id) return; if (!props.isEdit) return;
if (gid == null || !form.id) {
// 清空所在组:回显状态失效并拦截提交,避免把上一组的角色经「主组兜底」误存到别的组
appRolesEchoed = false;
return;
}
// 切组即置为未就绪:两组数据任一未回来前不允许提交
appRolesEchoed = false;
Apis.SysUser.userIdentity({ params: { userId: form.id, groupId: gid }, cacheFor: null }) Apis.SysUser.userIdentity({ params: { userId: form.id, groupId: gid }, cacheFor: null })
.then((resp: any) => { .then((resp: any) => {
const roleIds = Array.isArray(resp?.roleIds) ? resp.roleIds.map((v: any) => Number(v)) : []; const roleIds = Array.isArray(resp?.roleIds) ? resp.roleIds.map((v: any) => Number(v)) : [];
@@ -201,6 +212,16 @@ watch(() => form.userGroupId, (gid: number | null) => {
// 全局拦截器已弹窗,这里仅记录日志避免二次弹窗;schoolId 保持 null 时提交会被后端拒绝,不会误清空 // 全局拦截器已弹窗,这里仅记录日志避免二次弹窗;schoolId 保持 null 时提交会被后端拒绝,不会误清空
console.warn('[UserForm] 加载用户身份失败:', e?.message || '未知错误'); console.warn('[UserForm] 加载用户身份失败:', e?.message || '未知错误');
}); });
Apis.GroupAuthz.userRoles({ params: { userId: form.id, groupId: gid }, cacheFor: null })
.then((resp: any) => {
const ids = Array.isArray(resp) ? resp.map((r: any) => Number(r?.id)).filter((v: any) => v != null) : [];
form.appRoles = ids;
appRolesEchoed = true;
})
.catch((e: any) => {
// 回显失败保持未就绪:提交会被拦截,不会把上一组的角色误存到本组
console.warn('[UserForm] 加载该组角色属性失败:', e?.message || '未知错误');
});
}); });
/* ---------- 校验规则 ---------- */ /* ---------- 校验规则 ---------- */
@@ -291,6 +312,11 @@ async function handleSubmit() {
Message.warning('用户身份数据加载中,请稍候再提交'); Message.warning('用户身份数据加载中,请稍候再提交');
return false; return false;
} }
// 编辑模式角色属性回显未就绪时拦截:避免把上一组的角色属性误存到当前组
if (props.isEdit && !appRolesEchoed) {
Message.warning('角色属性加载中,请稍候再提交');
return false;
}
// 勾选由非空清成空:二次确认(空数组提交 = 清空该校身份) // 勾选由非空清成空:二次确认(空数组提交 = 清空该校身份)
if (props.isEdit && baselineUserType.length > 0 && form.userType.length === 0) { if (props.isEdit && baselineUserType.length > 0 && form.userType.length === 0) {
const ok = await MyConfirm({ const ok = await MyConfirm({
+70 -2
View File
@@ -20,7 +20,13 @@
<pre class="json-pre">{{ formatJson(row?.requestParams) }}</pre> <pre class="json-pre">{{ formatJson(row?.requestParams) }}</pre>
</FormItem> </FormItem>
<FormItem label="响应结果:"> <FormItem label="响应结果:">
<pre class="json-pre">{{ formatJson(row?.responseData) }}</pre> <template v-if="row?.id">
<a href="javascript:;" class="download-link" @click.prevent="handleDownload">下载</a>
<a v-if="canPreview" href="javascript:;" class="download-link" style="margin-left: 12px"
@click.prevent="handlePreview">在线预览</a>
<span v-if="sizeText" class="sub-text">({{ sizeText }})</span>
</template>
<span v-else>-</span>
</FormItem> </FormItem>
<FormItem label="创建时间:"> <FormItem label="创建时间:">
<span>{{ formatTime(row?.createTime) }}</span> <span>{{ formatTime(row?.createTime) }}</span>
@@ -31,10 +37,18 @@
<Button type="primary" @click="show = false">确定</Button> <Button type="primary" @click="show = false">确定</Button>
</template> </template>
</Modal> </Modal>
<Modal v-model="previewVisible" title="响应结果预览" width="760" :mask-closable="true">
<pre class="json-pre">{{ previewText }}</pre>
<template #footer>
<Button @click="previewVisible = false">关闭</Button>
</template>
</Modal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, ref } from 'vue';
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
import Utils from '@/utils'; import Utils from '@/utils';
interface Props { interface Props {
@@ -81,6 +95,32 @@ function formatTime(val: any): string {
if (!val) return '-'; if (!val) return '-';
return Utils.dateFormat(val, 'yyyy-MM-dd HH:mm:ss'); return Utils.dateFormat(val, 'yyyy-MM-dd HH:mm:ss');
} }
const { $conf } = useCurrentInstance();
const previewVisible = ref(false);
const previewText = ref('');
const canPreview = computed(() => Utils.canPreview(props.row?.responseSize));
const sizeText = computed(() =>
props.row?.responseSize != null && props.row.responseSize > 0 ? Utils.getFileSize(props.row.responseSize) : ''
);
function handleDownload() {
if (!props.row?.id) return;
location.href = `${$conf.serviceHost}/api/oapilog/Download/${props.row.id}`;
}
async function handlePreview() {
if (!props.row?.id) return;
previewText.value = '加载中…';
previewVisible.value = true;
try {
const res = await fetch(`${$conf.serviceHost}/api/oapilog/Download/${props.row.id}`, { credentials: 'include' });
previewText.value = formatJson(await res.text());
} catch (e: any) {
previewText.value = '预览加载失败:' + (e?.message || e);
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -116,4 +156,32 @@ function formatTime(val: any): string {
line-height: 1.6; line-height: 1.6;
} }
} }
.download-link {
color: var(--brand);
&:hover {
color: var(--brand-2);
}
}
.sub-text {
margin-left: 8px;
color: #999;
font-size: 12px;
}
.json-pre {
margin: 0;
padding: 10px 12px;
background: #f7f8fa;
border: 1px solid #e8eaec;
border-radius: 4px;
max-height: 420px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
font-size: 13px;
line-height: 1.6;
}
</style> </style>
+14 -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' },
@@ -151,24 +147,23 @@ const columns = [
align: 'right', align: 'right',
render: (h: any, { row }: any) => h('span', row.costTime != null ? `${row.costTime} ms` : '-'), render: (h: any, { row }: any) => h('span', row.costTime != null ? `${row.costTime} ms` : '-'),
}, },
{
title: '响应',
key: 'responseSize',
width: 90,
align: 'right',
render: (h: any, { row }: any) => h('span', row.responseSize != null && row.responseSize > 0 ? Utils.getFileSize(row.responseSize) : '-'),
},
{ {
title: '调用时间', 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);
+49 -4
View File
@@ -20,7 +20,12 @@
<pre class="json-pre">{{ formatJson(row?.requestParams) }}</pre> <pre class="json-pre">{{ formatJson(row?.requestParams) }}</pre>
</FormItem> </FormItem>
<FormItem label="数据:"> <FormItem label="数据:">
<a v-if="row?.id" href="javascript:;" class="download-link" @click.prevent="handleDownload">下载</a> <template v-if="row?.id">
<a href="javascript:;" class="download-link" @click.prevent="handleDownload">下载</a>
<a v-if="canPreview" href="javascript:;" class="download-link" style="margin-left: 12px"
@click.prevent="handlePreview">在线预览</a>
<span v-if="sizeText" class="sub-text">({{ sizeText }})</span>
</template>
<span v-else>-</span> <span v-else>-</span>
</FormItem> </FormItem>
<FormItem label="创建时间:"> <FormItem label="创建时间:">
@@ -32,10 +37,17 @@
<Button type="primary" @click="show = false">确定</Button> <Button type="primary" @click="show = false">确定</Button>
</template> </template>
</Modal> </Modal>
<Modal v-model="previewVisible" title="响应结果预览" width="760" :mask-closable="true">
<pre class="json-pre">{{ previewText }}</pre>
<template #footer>
<Button @click="previewVisible = false">关闭</Button>
</template>
</Modal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, ref } from 'vue';
import useCurrentInstance from '@/utils/hook/useCurrentInstance'; import useCurrentInstance from '@/utils/hook/useCurrentInstance';
import Utils from '@/utils'; import Utils from '@/utils';
@@ -83,6 +95,25 @@ function handleDownload() {
if (!props.row?.id) return; if (!props.row?.id) return;
location.href = `${$conf.serviceHost}/api/oplog/Download/${props.row.id}`; location.href = `${$conf.serviceHost}/api/oplog/Download/${props.row.id}`;
} }
const previewVisible = ref(false);
const previewText = ref('');
const canPreview = computed(() => Utils.canPreview(props.row?.responseSize));
const sizeText = computed(() =>
props.row?.responseSize != null && props.row.responseSize > 0 ? Utils.getFileSize(props.row.responseSize) : ''
);
async function handlePreview() {
if (!props.row?.id) return;
previewText.value = '加载中…';
previewVisible.value = true;
try {
const res = await fetch(`${$conf.serviceHost}/api/oplog/Download/${props.row.id}`, { credentials: 'include' });
previewText.value = formatJson(await res.text());
} catch (e: any) {
previewText.value = '预览加载失败:' + (e?.message || e);
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -119,11 +150,25 @@ function handleDownload() {
} }
.download-link { .download-link {
color: #577afb; color: var(--brand);
&:hover { &:hover {
color: #3d5afe; color: var(--brand-2);
} }
} }
} }
.json-pre {
margin: 0;
padding: 10px 12px;
background: #f7f8fa;
border: 1px solid #e8eaec;
border-radius: 4px;
max-height: 420px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
font-size: 13px;
line-height: 1.6;
}
</style> </style>
+15 -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' },
@@ -153,23 +149,22 @@ const columns = [
render: (h: any, { row }: any) => h('span', row.costTime != null ? `${row.costTime} ms` : '-'), render: (h: any, { row }: any) => h('span', row.costTime != null ? `${row.costTime} ms` : '-'),
}, },
{ {
title: '操作时间ed', title: '响应',
key: 'responseSize',
width: 90,
align: 'right',
render: (h: any, { row }: any) => h('span', row.responseSize != null && row.responseSize > 0 ? Utils.getFileSize(row.responseSize) : '-'),
},
{
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);
+13 -30
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 },
@@ -157,22 +153,17 @@ const columns = [
render: (h: any, { row }: any) => h('span', row.costMs != null ? `${row.costMs}` : '-'), render: (h: any, { row }: any) => h('span', row.costMs != null ? `${row.costMs}` : '-'),
}, },
{ {
title: '操作', title: '请求体',
key: 'action', key: 'payloadSize',
width: 150, width: 100,
align: 'left', align: 'right',
render: (h: any, { row }: any) => { render: (h: any, { row }: any) => h('span', row.payloadSize != null && row.payloadSize > 0 ? Utils.getFileSize(row.payloadSize) : '-'),
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);
},
}, },
// 重试按行数据(成功与否)变化 → disabledWhen 置灰保位(§5.2 按钮位置稳定性红线),禁止 when 隐藏
actionColumn([
{ key: 'detail', label: '查看详情', on: (r: any) => openDetail(r) },
{ key: 'retry', label: '重试', on: (r: any) => handleRetry(r), danger: true, disabledWhen: (r: any) => r.success === 1 },
], { mode: 'text', width: 150 }),
]; ];
const detailVisible = ref(false); const detailVisible = ref(false);
@@ -218,12 +209,4 @@ loadApps();
vertical-align: middle; vertical-align: middle;
} }
} }
.text-success {
color: #19be6b;
}
.text-error {
color: #ed4014;
}
</style> </style>
+59 -4
View File
@@ -43,7 +43,12 @@
<span>{{ formatTime(row?.createdAt) }}</span> <span>{{ formatTime(row?.createdAt) }}</span>
</FormItem> </FormItem>
<FormItem label="请求体:"> <FormItem label="请求体:">
<a v-if="row?.id" href="javascript:;" class="download-link" @click.prevent="handleDownload">下载请求体</a> <template v-if="row?.id">
<a href="javascript:;" class="download-link" @click.prevent="handleDownload">下载请求体</a>
<a v-if="canPreview" href="javascript:;" class="download-link" style="margin-left: 12px"
@click.prevent="handlePreview">在线预览</a>
<span v-if="sizeText" class="sub-text">({{ sizeText }})</span>
</template>
<span v-else>-</span> <span v-else>-</span>
</FormItem> </FormItem>
</Form> </Form>
@@ -51,10 +56,17 @@
<Button type="primary" @click="show = false">确定</Button> <Button type="primary" @click="show = false">确定</Button>
</template> </template>
</Modal> </Modal>
<Modal v-model="previewVisible" title="请求体预览" width="760" :mask-closable="true">
<pre class="json-pre">{{ previewText }}</pre>
<template #footer>
<Button @click="previewVisible = false">关闭</Button>
</template>
</Modal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed, ref } from 'vue';
import useCurrentInstance from '@/utils/hook/useCurrentInstance'; import useCurrentInstance from '@/utils/hook/useCurrentInstance';
import Utils from '@/utils'; import Utils from '@/utils';
@@ -87,11 +99,40 @@ function onVisibleChange(val: boolean) {
const { $conf } = useCurrentInstance(); const { $conf } = useCurrentInstance();
const previewVisible = ref(false);
const previewText = ref('');
const canPreview = computed(() => Utils.canPreview(props.row?.payloadSize));
const sizeText = computed(() =>
props.row?.payloadSize != null && props.row.payloadSize > 0 ? Utils.getFileSize(props.row.payloadSize) : ''
);
function handleDownload() { function handleDownload() {
if (!props.row?.id) return; if (!props.row?.id) return;
location.href = `${$conf.serviceHost}/api/push/Download/${props.row.id}`; location.href = `${$conf.serviceHost}/api/push/Download/${props.row.id}`;
} }
async function handlePreview() {
if (!props.row?.id) return;
previewText.value = '加载中…';
previewVisible.value = true;
try {
const res = await fetch(`${$conf.serviceHost}/api/push/Download/${props.row.id}`, { credentials: 'include' });
previewText.value = formatJson(await res.text());
} catch (e: any) {
previewText.value = '预览加载失败:' + (e?.message || e);
}
}
function formatJson(val: string): string {
if (!val) return '-';
try {
return JSON.stringify(JSON.parse(val), null, 2);
} catch {
return val;
}
}
function formatTime(val: any): string { function formatTime(val: any): string {
if (!val) return '-'; if (!val) return '-';
return Utils.dateFormat(val, 'yyyy-MM-dd HH:mm:ss'); return Utils.dateFormat(val, 'yyyy-MM-dd HH:mm:ss');
@@ -128,11 +169,25 @@ function formatTime(val: any): string {
} }
.download-link { .download-link {
color: #577afb; color: var(--brand);
&:hover { &:hover {
color: #3d5afe; color: var(--brand-2);
} }
} }
} }
.json-pre {
margin: 0;
padding: 10px 12px;
background: #f7f8fa;
border: 1px solid #e8eaec;
border-radius: 4px;
max-height: 420px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
font-size: 13px;
line-height: 1.6;
}
</style> </style>
-242
View File
@@ -1,242 +0,0 @@
<template>
<div class="main-wrapper">
<div class="main-container">
<div class="test-card">
<h3 class="card-title">测试推送</h3>
<p class="card-desc">
手动触发一次推送(投递消息队列,由订阅方异步接收)。下方将展示本次请求预览(URL / 头 / 体),
实际响应结果与耗时请在「推送日志」中查看。
</p>
<Form :label-width="100" class="test-form">
<FormItem label="订阅方应用" required>
<Select v-model="form.appId" filterable placeholder="选择要推送的订阅方应用" style="width: 360px">
<Option v-for="opt in appOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</Option>
</Select>
</FormItem>
<FormItem label="订阅接口" required>
<Select v-model="form.interfacePath" filterable placeholder="选择接口" style="width: 360px"
@on-change="onInterfaceChange">
<Option v-for="opt in interfaceOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</Option>
</Select>
</FormItem>
<FormItem label="目标ID" :required="!isSync">
<Input v-model="targetIdsText" :disabled="isSync" :placeholder="isSync ? '全量同步无需指定' : '可选,逗号分隔,如 10,11,12'"
style="width: 360px" />
</FormItem>
<FormItem>
<Button type="primary" :loading="sending" @click="handleSend">发送测试推送</Button>
</FormItem>
</Form>
</div>
<div v-if="preview" class="result-card">
<div class="result-head">
<h3 class="card-title">请求预览</h3>
<Button type="text" @click="goLog">查看推送日志</Button>
</div>
<Alert v-if="preview.batch && preview.batch > 1" type="info" show-icon>
本次将拆分为 {{ preview.batch }} 条消息依次发送
</Alert>
<Form :label-width="100" class="result-form">
<FormItem label="请求URL:">
<span class="break-all">{{ preview.url }}</span>
</FormItem>
<FormItem label="请求方法:">
<span>{{ preview.method }}</span>
</FormItem>
<FormItem label="请求头:">
<pre class="kv-pre">{{ formatHeaders(preview.headers) }}</pre>
</FormItem>
<FormItem label="请求体:">
<pre class="json-pre">{{ formatJson(preview.body) }}</pre>
</FormItem>
</Form>
<Alert type="success" show-icon>{{ sendMessage }}</Alert>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Message } from 'view-ui-plus';
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
defineOptions({ name: 'PushTest' });
const router = useRouter();
const { $utils } = useCurrentInstance();
const interfaceOptions = [
{ value: 'group/SyncGroup', label: '组全量同步', sync: true },
{ value: 'user/SyncUser', label: '用户全量同步', sync: true },
{ value: 'group/AddGroup', label: '添加组', sync: false },
{ value: 'group/UpdateGroup', label: '更新组', sync: false },
{ value: 'group/DeleteGroup', label: '删除组', sync: false },
{ value: 'user/AddUser', label: '添加用户', sync: false },
{ value: 'user/UpdateUser', label: '更新用户', sync: false },
{ value: 'user/DeleteUser', label: '删除用户', sync: false },
];
const appOptions = ref<{ value: number; label: string }[]>([]);
const form = ref({ appId: undefined as number | undefined, interfacePath: '' });
const targetIdsText = ref('');
const sending = ref(false);
const preview = ref<Record<string, any> | null>(null);
const sendMessage = ref('');
const isSync = computed(() => {
const opt = interfaceOptions.find((o) => o.value === form.value.interfacePath);
return !!opt?.sync;
});
function onInterfaceChange() {
if (isSync.value) targetIdsText.value = '';
}
function goLog() {
router.push('/push-log');
}
function formatHeaders(headers: Record<string, string> | undefined): string {
if (!headers) return '-';
return Object.entries(headers)
.map(([k, v]) => `${k}: ${v}`)
.join('\n');
}
function formatJson(val: any): string {
if (val == null) return '-';
if (typeof val === 'string') {
try {
return JSON.stringify(JSON.parse(val), null, 2);
} catch {
return val;
}
}
return JSON.stringify(val, null, 2);
}
function parseTargetIds(): number[] | null {
if (isSync.value) return null;
const txt = targetIdsText.value.trim();
if (!txt) return null;
const ids = txt
.split(/[,,\s]+/)
.map((s) => Number(s.trim()))
.filter((n) => Number.isFinite(n));
return ids.length ? ids : null;
}
function handleSend() {
if (form.value.appId == null) {
Message.error('请选择订阅方应用');
return;
}
if (!form.value.interfacePath) {
Message.error('请选择订阅接口');
return;
}
sending.value = true;
preview.value = null;
sendMessage.value = '';
(Apis as any)
.PushSubscription.test({
data: {
appId: form.value.appId,
interfacePath: form.value.interfacePath,
targetIds: parseTargetIds(),
},
cacheFor: null,
})
.then((res: any) => {
preview.value = res?.preview || null;
sendMessage.value = res?.message || '已发送';
})
.catch((e: any) => console.warn(e?.message || '发送失败'))
.finally(() => (sending.value = false));
}
async function loadApps() {
try {
const res = await (Apis as any).AppSettings.appList({
params: { pageIndex: 1, pageSize: 9999 },
cacheFor: null,
});
const list = (res && res.content) || [];
appOptions.value = list.map((a: any) => ({ value: a.id, label: `${a.name}(${a.appKey})` }));
} catch (e: any) {
appOptions.value = [];
}
}
loadApps();
</script>
<style lang="scss" scoped>
.main-wrapper {
height: 100%;
}
.main-container {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.test-card,
.result-card {
background: #fff;
border-radius: 4px;
padding: 16px 20px;
}
.card-title {
margin: 0 0 6px;
font-size: 15px;
font-weight: 600;
}
.card-desc {
margin: 0 0 12px;
color: #808695;
font-size: 13px;
line-height: 1.6;
}
.test-form {
margin-top: 4px;
}
.result-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.break-all {
word-break: break-all;
}
.kv-pre,
.json-pre {
margin: 0;
padding: 10px 12px;
background: #f7f8fa;
border: 1px solid #e8eaec;
border-radius: 4px;
max-height: 280px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
font-size: 13px;
line-height: 1.6;
}
.kv-pre {
max-height: 140px;
}
</style>
+90 -67
View File
@@ -26,10 +26,16 @@
:type="collapsedMap[String(item.cat.id)] ? 'ios-arrow-forward' : 'ios-arrow-down'" /> :type="collapsedMap[String(item.cat.id)] ? 'ios-arrow-forward' : 'ios-arrow-down'" />
<span class="cat-name" :title="item.cat.name">{{ item.cat.name }}</span> <span class="cat-name" :title="item.cat.name">{{ item.cat.name }}</span>
<span class="cat-count">{{ item.treeData.length }}个应用</span> <span class="cat-count">{{ item.treeData.length }}个应用</span>
<span class="cat-spacer"></span>
<!-- 开关外层 @click.stop:否则点击会冒泡到卡片头部触发折叠 -->
<span class="expand-all" @click.stop>
<span class="expand-all-label">全部展开:</span>
<Switch size="small" v-model="expandAllMap[String(item.cat.id)]" />
</span>
</div> </div>
<div v-show="!collapsedMap[String(item.cat.id)]" class="cat-card-body"> <div v-show="!collapsedMap[String(item.cat.id)]" class="cat-card-body">
<MyTree :treeData="item.treeData" :setting="treeSetting" :autoClick="false" <MyTree :treeData="item.treeData" :setting="treeSetting" :autoClick="false" :isCheck="true"
@on-tree-check="onTreeCheck" /> :expandAll="!!expandAllMap[String(item.cat.id)]" @on-tree-check="onTreeCheck" />
</div> </div>
</div> </div>
</div> </div>
@@ -47,6 +53,7 @@ import { computed, ref, shallowRef, reactive, watch } from "vue";
import { debounce } from "lodash"; import { debounce } from "lodash";
import Apis from "@/api"; import Apis from "@/api";
import MyTree from "@/components/MyTree.vue"; import MyTree from "@/components/MyTree.vue";
import { syncAppModulesFromTree, walkModules } from "@/utils/treeCascade";
const props = defineProps<{ roleId: number | null; roleName?: string }>(); const props = defineProps<{ roleId: number | null; roleName?: string }>();
@@ -57,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;
@@ -67,12 +74,12 @@ type AuthzTreeNode = {
nodeType: "app" | "module"; nodeType: "app" | "module";
rawId: number; rawId: number;
appId: number; appId: number;
isParent?: boolean;
children?: AuthzTreeNode[]; children?: AuthzTreeNode[];
}; };
type CategoryTreeItem = { cat: CategoryItem; treeData: AuthzTreeNode[]; weight: number }; type CategoryTreeItem = { cat: CategoryItem; treeData: AuthzTreeNode[]; weight: number };
const COLUMN_COUNT = 3; const COLUMN_COUNT = 3;
const jq = () => (window as any).$;
const loading = ref(false); const loading = ref(false);
const categoryList = shallowRef<CategoryItem[]>([]); const categoryList = shallowRef<CategoryItem[]>([]);
@@ -81,13 +88,17 @@ const searchKeyword = ref("");
const appliedKeyword = ref(""); const appliedKeyword = ref("");
const collapsedMap = reactive<Record<string, boolean>>({}); const collapsedMap = reactive<Record<string, boolean>>({});
/** 各应用类型卡片的「全部展开」开关(默认关闭):状态随卡片独立保存,数据重载后仍生效 */
const expandAllMap = reactive<Record<string, boolean>>({});
const treeSetting = { const treeSetting = {
view: { dblClickExpand: false, showIcon: false }, view: { dblClickExpand: false, showIcon: false },
check: { check: {
enable: true, enable: true,
chkStyle: "checkbox", chkStyle: "checkbox",
// 不自动级联:模块逐条独立授权(角色无"子组继承"概念) // 原生级联即可满足需求:
chkboxType: { Y: "", N: "" }, // Y="ps" 勾选子->父/祖先全部勾选、勾选父->子孙全部勾选;N="s" 取消父->子孙全部取消、取消子->父/祖先不受影响
chkboxType: { Y: "ps", N: "s" },
}, },
callback: {}, callback: {},
}; };
@@ -107,37 +118,48 @@ 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 而渲染 +/−(叶子也会出现展开图标)
function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode { function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
return { const kids = (m.children || []).map((c) => buildModuleNode(c, appId));
const node: 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,
children: (m.children || []).map((c) => buildModuleNode(c, appId)), isParent: kids.length > 0,
}; };
if (kids.length > 0) node.children = kids;
return node;
} }
function buildAppNode(app: AppItem): AuthzTreeNode { function buildAppNode(app: AppItem): AuthzTreeNode {
const appId = Number(app.id); const appId = Number(app.id);
return { const kids = (app.modules || []).map((m) => buildModuleNode(m, appId));
const node: AuthzTreeNode = {
id: `app_${appId}`, id: `app_${appId}`,
name: String(app.name || ""), name: String(app.name || ""),
open: true, open: false,
checked: isAppChecked(app), checked: isAppChecked(app),
nodeType: "app", nodeType: "app",
rawId: appId, rawId: appId,
appId, appId,
children: (app.modules || []).map((m) => buildModuleNode(m, appId)), isParent: kids.length > 0,
}; };
if (kids.length > 0) node.children = kids;
return node;
} }
// ---------------- 数据加载 ---------------- // ---------------- 数据加载 ----------------
@@ -228,57 +250,20 @@ function findLocalApp(appId: number): AppItem | null {
} }
return null; return null;
} }
function walkModules(nodes: AppModuleVo[], fn: (n: AppModuleVo) => void) { // walkModules / 本地勾选态同步(syncAppModulesFromTree)统一在 utils/treeCascade.ts
for (const node of nodes) {
fn(node);
walkModules(node.children || [], fn);
}
}
function setLocalAppChecked(appId: number, checked: boolean) {
const app = findLocalApp(appId);
if (!app) return;
walkModules(app.modules || [], (n) => { n.checked = checked; });
}
function setLocalModuleState(appId: number, moduleId: number, patch: Partial<AppModuleVo>) {
const app = findLocalApp(appId);
if (!app) return;
walkModules(app.modules || [], (n) => { if (Number(n.id) === moduleId) Object.assign(n, patch); });
}
function getTreeObjByNode(node: any) {
const $ = jq();
const treeId = String(node?.tId || "").replace(/_\d+$/, "");
return $?.fn?.zTree?.getZTreeObj(treeId) || null;
}
function syncAppNodeState(treeObj: any, appId: number) {
if (!treeObj) return;
const app = findLocalApp(appId);
if (!app) return;
const appNode = treeObj.getNodesByFilter((n: any) => n.id === `app_${appId}`, true);
if (!appNode) return;
appNode.checked = isAppChecked(app);
treeObj.checkNode(appNode, appNode.checked, false);
}
// ---------------- 授权操作(勾选即保存,全量覆盖 + 防抖) ---------------- // ---------------- 授权操作(级联由 zTree 原生 chkboxType 完成,勾选即保存,全量覆盖 + 防抖) ----------------
function onTreeCheck(node: any) { function onTreeCheck(node: any) {
const roleId = props.roleId; const roleId = props.roleId;
if (!roleId || !node) return; if (!roleId || !node) return;
const checked = !!node.checked; // zTree 原生级联已生效,把该应用最终勾选态同步回本地数据(saveNow 的提交依据)
const isApp = node.nodeType === "app"; const app = findLocalApp(node.appId);
const treeObj = getTreeObjByNode(node); if (!app) return;
if ((app.modules || []).length === 0) {
if (isApp) { // 无子模块应用:没有模块明细可同步,直接记整应用勾选态
setLocalAppChecked(node.appId, checked); app.checked = !!node.checked;
if (treeObj) {
const affected = treeObj.transformToArray([node]) || [];
affected.forEach((n: any) => {
if (n !== node) treeObj.checkNode(n, checked, false);
n.checked = checked;
});
}
} else { } else {
setLocalModuleState(node.appId, node.rawId, { checked }); syncAppModulesFromTree(app, node);
syncAppNodeState(treeObj, node.appId);
} }
scheduleSave(); scheduleSave();
} }
@@ -289,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 {
@@ -323,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;
@@ -367,7 +359,7 @@ watch(
} }
.role-name { .role-name {
color: #2d8cf0; color: var(--brand);
font-weight: 600; font-weight: 600;
} }
@@ -418,6 +410,9 @@ watch(
font-size: 13px; font-size: 13px;
color: #333; color: #333;
margin-left: 6px; margin-left: 6px;
/* 列宽有限:分类名优先让位(省略号),保证右侧「全部展开」不被挤出可视区 */
flex: 0 999 auto;
min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -427,20 +422,48 @@ watch(
color: #999; color: #999;
font-size: 12px; font-size: 12px;
margin-left: 8px; margin-left: 8px;
flex: 1; flex: 0 10 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap; white-space: nowrap;
} }
/* 弹性空隙:把「全部展开」整组推到卡片头右端 */
.cat-spacer {
flex: 1 1 0;
min-width: 0;
}
.cat-card-body { .cat-card-body {
padding: 6px 8px; padding: 6px 8px;
} }
.expand-all {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 4px;
}
.expand-all-label {
font-size: 12px;
color: #999;
white-space: nowrap;
}
.empty-tip { .empty-tip {
color: #999; color: #999;
padding: 48px 0; padding: 48px 0;
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);
} }
} }
+25 -6
View File
@@ -37,7 +37,8 @@ import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
const props = defineProps<{ const props = defineProps<{
roleId: number; roleId: number;
existingUserIds: number[]; /** 已有「用户×组」绑定明细,用于按 (用户, 当前选中组) 去重/禁用 */
existingBindings: { id: number; groupId: number }[];
visible: boolean; visible: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -60,7 +61,10 @@ const saving = ref(false);
// 组织树数据源(与组用户管理一致) // 组织树数据源(与组用户管理一致)
const treeApi = () => Apis.SysGroup.getTree(); const treeApi = () => Apis.SysGroup.getTree();
const existingSet = computed(() => new Set(props.existingUserIds ?? [])); // 已有绑定集合:键为 `${userId}:${groupId}`,同一用户可在不同组下分别绑定
const existingKeys = computed(() =>
new Set((props.existingBindings ?? []).map(b => `${b.id}:${b.groupId}`))
);
const filteredCandidates = computed(() => { const filteredCandidates = computed(() => {
const k = kw.value.trim().toLowerCase(); const k = kw.value.trim().toLowerCase();
@@ -73,7 +77,7 @@ const filteredCandidates = computed(() => {
}); });
function isExisting(u: any) { function isExisting(u: any) {
return existingSet.value.has(u.id); return groupId.value != null && existingKeys.value.has(`${u.id}:${groupId.value}`);
} }
function isChecked(u: any) { function isChecked(u: any) {
return isExisting(u) || selectedIds.value.has(u.id); return isExisting(u) || selectedIds.value.has(u.id);
@@ -88,6 +92,8 @@ function toggle(u: any, v: boolean) {
async function onTreeChange(node: any) { async function onTreeChange(node: any) {
groupId.value = node?.id ?? null; groupId.value = node?.id ?? null;
// 绑定记「勾选时所在组」:切换组即清空勾选,避免把 A 组下勾的人误绑到 B 组
selectedIds.value = new Set();
if (!groupId.value) { if (!groupId.value) {
candidates.value = []; candidates.value = [];
return; return;
@@ -113,12 +119,25 @@ function onVisibleChange(v: boolean) {
} }
async function save() { async function save() {
if (!groupId.value) {
Message.warning("请先选择左侧组织节点");
return;
}
if (!selectedIds.value.size) {
Message.warning("请先勾选要添加的用户");
return;
}
saving.value = true; saving.value = true;
try { try {
// 全量覆盖:现有绑定 ∪ 本次新选 // 增量添加「用户×组」绑定(组 = 左侧树当前选中组);后端对已存在的 (userId, groupId) 幂等跳过
const all = new Set<number>([...existingSet.value, ...selectedIds.value]);
await Apis.AppRole.saveRoleUsers( await Apis.AppRole.saveRoleUsers(
{ data: { roleId: props.roleId, userIds: [...all] }, cacheFor: null } {
data: {
roleId: props.roleId,
bindings: [...selectedIds.value].map(id => ({ userId: id, groupId: groupId.value as number })),
},
cacheFor: null,
}
); );
Message.success("添加成功"); Message.success("添加成功");
emit("success"); emit("success");
+22 -17
View File
@@ -1,13 +1,13 @@
<template> <template>
<div class="role-user-manage"> <div class="role-user-manage">
<div class="rum-toolbar"> <div class="rum-toolbar">
<span class="rum-count">共 {{ userList.length }} 位用户</span> <span class="rum-count">共 {{ userList.length }} 条绑定</span>
<Button type="primary" icon="md-person-add" :disabled="!roleId" @click="openBind">添加用户</Button> <Button type="primary" icon="md-person-add" :disabled="!roleId" @click="openBind">添加用户</Button>
</div> </div>
<Table :columns="columns" :data="userList" :loading="loading" size="small" border></Table> <Table :columns="columns" :data="userList" :loading="loading" size="small" border></Table>
<RoleUserBindModal v-model:visible="bindVisible" :role-id="roleId" :existing-user-ids="existingIds" <RoleUserBindModal v-model:visible="bindVisible" :role-id="roleId" :existing-bindings="existingBindings"
@success="onBindSuccess" /> @success="onBindSuccess" />
</div> </div>
</template> </template>
@@ -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;
@@ -31,16 +29,23 @@ const userList = ref<any[]>([]);
const loading = ref(false); const loading = ref(false);
const bindVisible = ref(false); const bindVisible = ref(false);
// 当前角色已绑定的用户 id 集合(传给弹窗做去重/禁用) // 当前角色已绑定的「用户×组」明细(传给弹窗按 (用户, 组) 去重/禁用)
const existingIds = computed(() => userList.value.map((u: any) => u.id)); const existingBindings = computed(() =>
userList.value
.filter((u: any) => u.id != null && u.groupId != null)
.map((u: any) => ({ id: u.id as number, groupId: u.groupId as number }))
);
// 部门列为查询时按 groupId 拼接的组路径(非超管按数据范围裁剪,学校管理员只看到本校路径)
// 账号/姓名用定宽、部门用 minWidth 吃剩余宽度,把空间留给组路径
const columns = [ const columns = [
{ title: "账号", key: "account", minWidth: 140 }, { title: "账号", key: "account", width: 110 },
{ title: "姓名", key: "realName", minWidth: 110 }, { title: "姓名", key: "realName", width: 90 },
{ { title: "部门", key: "groupPath", minWidth: 200 },
title: "操作", key: "action", width: 90, align: "center", // UserBase 恒文字链(工程风格显式声明):单删除动作保持红色文字链,列宽 80 不变
render: (h: any, { row }: any) => $utils.operatingTextHandle(h, "删除", "text-error", () => removeUser(row)), actionColumn([
}, { key: "del", label: "删除", icon: "icon-ashbin", on: (r: any) => removeUser(r), danger: true },
], { mode: "text", width: 80 }),
]; ];
async function loadUsers() { async function loadUsers() {
@@ -61,13 +66,13 @@ async function loadUsers() {
function removeUser(row: any) { function removeUser(row: any) {
MyConfirm({ MyConfirm({
title: "移除用户", title: "移除绑定",
msg: `确定将用户【${row.realName || row.account}】从此角色移除?`, msg: `确定将用户【${row.realName || row.account}】在部门【${row.groupPath || '未知'}】下的该角色移除?`,
info: "移除后该用户不再拥有此角色所授权的权限。", info: "移除后该用户在该组下不再拥有此角色所授权的权限;其在其他组下的绑定不受影响。",
async onOk() { async onOk() {
try { try {
await Apis.AppRole.removeRoleUser( await Apis.AppRole.removeRoleUser(
{ params: { roleId: props.roleId, userId: row.id }, cacheFor: null } { params: { roleId: props.roleId, userId: row.id, groupId: row.groupId }, cacheFor: null }
); );
Message.success("已移除"); Message.success("已移除");
await loadUsers(); await loadUsers();
+26 -9
View File
@@ -99,15 +99,32 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
server: { server: {
host: "localhost", host: "localhost",
port: 8130, port: 8130,
// proxy: { proxy: {
// // //api是自行设置的请求前缀,任何请求路径以/api开头的请求将被代理到对应的target目标 // 主题通道本地联调(唯一必需项):dev 下 index.html 的 loader 走同源相对(见其注释的优先级四档),
// // "/api": { // manifest/CSS 经此代理到中央通道;changeOrigin 顺带把 Host 改写成目标域,Caddy 按 Host 匹配站点。
// // target: "http://localhost:8064/", // 联调本地通道源:YschTheme 侧 npm run serve:static(默认 9527),再用
// // // target: "http://debugold.hongyucloud.com/ChildOptCourse", // YH_THEME_PROXY_TARGET=http://localhost:9527 覆盖本 target。prod 同源用不到这段。
// // changeOrigin: true, //需要代理跨域 "/static": {
// // rewrite: (path: any) => path.replace(/^\/api/, "/api/"), //路径重写,把'/api'替换为'' target: process.env.YH_THEME_PROXY_TARGET || "http://base.yunhaixingtong.com",
// // }, changeOrigin: true,
// }, },
// 后端接口同源代理:alova 的 baseURL 在非 mock 时取 .env 的 VITE_SERVICE_HOST,
// 想"只换后端不改 .env"就把 VITE_SERVICE_HOST 留空 → 请求落同源 /api、/mobiapi → 由此处代理决定去向。
// 本地后端(默认):npm run dev
// 线上后端: YH_API_PROXY_TARGET=http://base.yunhaixingtong.com/userbase npm run dev
// (changeOrigin 已改写 Host,Caddy 按 Host 匹配;但 CAS 登录 cookie 属
// yunhaixingtong 域,localhost 侧带不上 → 未登录接口会被 302 到 SSO,
// 需要真数据时优先配合 VITE_IS_MOCK=false + 本地后端,或先在同浏览器登录线上)
// mock 模式(VITE_IS_MOCK=true)下已注册的接口由 vite-plugin-mock 直接应答,未注册的仍透传到本 target。
"/api": {
target: process.env.YH_API_PROXY_TARGET || "http://localhost:8060",
changeOrigin: true,
},
"/mobiapi": {
target: process.env.YH_API_PROXY_TARGET || "http://localhost:8060",
changeOrigin: true,
},
},
}, },
// css预处理器 // css预处理器
css: { css: {