Compare commits

...
36 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
60 changed files with 5009 additions and 699 deletions
+8
View File
@@ -1,9 +1,17 @@
# 开发环境 (dev):仅 vite dev 加载(--mode dev)。值可自由指本地或线上地址。
# build 产物不读此文件(build 默认 production mode,只认 .env / .env.production,两者均不存在),
# 线上环境差异全部由 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_SSO_HOST=http://sso.yunhaixingtong.com
VITE_SERVICE_HOST=http://localhost:8060
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
VITE_IS_MOCK=false
# 主题通道 dev 跨源直连(可选,需通道已开 CORS);留空则同源相对 → 走 vite 的 /static 代理
VITE_THEME_HOST=
+31 -8
View File
@@ -19,7 +19,22 @@ jobs:
options: --network infra-infra-net
steps:
- name: 安装 git / curl / zip(alpine)
run: apk add --no-cache git curl zip
run: |
set -e
# runner 容器偶发拉不到公网 apk 源(dl-cdn.alpinelinux.org 报 Permission denied 并卡满 60s×2),
# 这里先按可达性挑一个能通的镜像源再装包,避免整条流水线卡死在第一步、后续步骤全被 skip
chosen=""
for host in mirrors.aliyun.com mirrors.huaweicloud.com mirrors.cloud.tencent.com dl-cdn.alpinelinux.org; do
if wget -q -T 6 -O /dev/null "https://${host}/alpine/v3.20/main/x86_64/APKINDEX.tar.gz"; then
chosen="${host}"
break
fi
echo "apk 源不可达: ${host}"
done
if [ -z "${chosen}" ]; then echo "所有 apk 源均不可达,请检查 runner 容器网络/代理"; exit 1; fi
echo "使用 apk 源: ${chosen}"
sed -i "s#dl-cdn.alpinelinux.org#${chosen}#g" /etc/apk/repositories
apk add --no-cache git curl zip
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
@@ -38,21 +53,29 @@ jobs:
- name: 构建前端(node 容器)
run: |
set -e
docker rm -f frontbuild 2>/dev/null || true
docker run -d --name frontbuild -w /build node:20 sleep 3600
docker cp "${PWD}/." frontbuild:/build/
docker exec frontbuild sh -c '
# 构建容器名必须全局唯一: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 frontbuild sh -c 'ls -la /build/dist/ | head -20'
docker exec "$BUILD_CONTAINER" sh -c 'ls -la /build/dist/ | head -20'
# 从容器 cp dist 到宿主工作目录
docker cp frontbuild:/build/dist ./dist
docker rm -f frontbuild
docker cp "$BUILD_CONTAINER":/build/dist ./dist
docker rm -f "$BUILD_CONTAINER"
echo "BUILD CHECK OK"
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
+20
View File
@@ -27,6 +27,26 @@
}
})();
</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>
<link rel="stylesheet" href="./libs/ztree/zTreeStyle.css" />
<script src="./libs/jquery.min.js"></script>
+4 -4
View File
@@ -16,7 +16,7 @@
"mockjs": "^1.1.0",
"pinia": "^2.1.4",
"sass": "^1.64.1",
"view-ui-plus": "^1.3.16",
"view-ui-plus": "^1.3.24",
"vite-plugin-mock": "2.9.6",
"vue": "^3.4.19",
"vue-router": "4",
@@ -8354,9 +8354,9 @@
}
},
"node_modules/view-ui-plus": {
"version": "1.3.21",
"resolved": "https://registry.npmmirror.com/view-ui-plus/-/view-ui-plus-1.3.21.tgz",
"integrity": "sha512-WvMd6klBnhgVjxJpNn/8Bpa/+aEWv1Ii5cMKvXMnsTebbOyaM6Qm4hEUBglXA/MPlTfcop7YkGC4eHHM+x3iCg==",
"version": "1.3.24",
"resolved": "https://registry.npmmirror.com/view-ui-plus/-/view-ui-plus-1.3.24.tgz",
"integrity": "sha512-umhDHnCPGIHCQYYyZDw3AfGSQqlUThR4/eEZjtzCFrrjLzXpE6I8eIggzZQGKHK3zKzRTopDYJsyeFb+K9MLuQ==",
"license": "MIT",
"dependencies": {
"async-validator": "^3.3.0",
+1 -1
View File
@@ -22,7 +22,7 @@
"mockjs": "^1.1.0",
"pinia": "^2.1.4",
"sass": "^1.64.1",
"view-ui-plus": "^1.3.16",
"view-ui-plus": "^1.3.24",
"vite-plugin-mock": "2.9.6",
"vue": "^3.4.19",
"vue-router": "4",
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)));
// 主题样式已本地化到 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) {
store.setUserRole(UserRoleEnum.SuperAdmin);
+20
View File
@@ -30,8 +30,28 @@ export default {
'SysGroup.move': ['POST', '/api/sysgroup/Move'],
'SysGroup.sysGroupEdit': ['POST', '/api/sysgroup/Edit/{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.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.retry': ['POST', '/api/push/Retry/{id}'],
'GroupAuthz.setSchoolApp': ['POST', '/api/groupauthz/SetSchoolApp'],
+633 -2
View File
@@ -148,6 +148,59 @@ export interface SysGroupRequest {
type: 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 {
appId?: number;
interfacePath?: string;
@@ -223,6 +276,8 @@ export interface ApplicationInfoRequest {
name: string;
categoryId: number;
desc?: string;
appCode?: string;
appNature?: string;
clients?: number[];
pcUrl?: string;
pcIcon?: string;
@@ -230,6 +285,7 @@ export interface ApplicationInfoRequest {
appIcon?: string;
officialAccountUrl?: string;
dingtalkUrl?: string;
miniprogramUrl?: string;
}
export interface SysUserMoveRequest {
userId: number;
@@ -241,6 +297,14 @@ export interface SysGroupVo {
name?: string;
type?: 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[];
}
export interface AppItem {
@@ -311,12 +375,14 @@ export interface SysUserVo {
email?: string;
gender?: number;
profilePicture?: string;
roles?: number[];
disabled?: number;
type?: number;
sortNo?: number;
groupStatus?: number;
/** 主校身份名(如「老师/校领导」):列表「用户身份」列展示字段(roles 列已停写) */
/**
* 用户身份名(如「老师、校领导」):列表「用户身份」列展示字段。
* 组上下文列表只含「当前组范围解析出的学校」下的身份(与编辑弹窗「所在组」同源);未选组才全量。
*/
identityNames?: string;
expired?: boolean;
createTime?: string;
@@ -764,6 +830,8 @@ export interface ApplicationInfoVo {
desc?: string;
appKey?: number;
appSecret?: string;
appCode?: string;
appNature?: string;
clients?: number[];
pcUrl?: string;
pcIcon?: string;
@@ -771,6 +839,7 @@ export interface ApplicationInfoVo {
appIcon?: string;
officialAccountUrl?: string;
dingtalkUrl?: string;
miniprogramUrl?: string;
}
export interface BasePageResultApplicationInfoVo {
total?: number;
@@ -1530,6 +1599,81 @@ declare global {
>(
config: 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>;
/**
* ---
*
@@ -1697,6 +1841,493 @@ declare global {
config?: 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: {
/**
* ---
+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(局端不开放:局端的应用动作是「开通」,应用维护是平台级)
* 3. 权限管理 —— 父目录:应用开通(S+B) / 组授权 / 角色授权 / 权限一览(S+B+C)
* 4. 日志管理 —— 仅超管(父目录)
* 5. 基础设置 —— 非业务域的基础配置合集(当前:部门管理),后续字典/参数/模板等杂项归此
*
* 菜单不渲染图标:本工程侧边栏只输出文字,节点无需 icon 字段。
*
* 每个节点带唯一 id;子节点的 pid 由 layout.vue 的 flaten() 扁平化时
* 依据父节点 id 自动推导,用于「高亮自身 + 面包屑展示父级」联动。
@@ -62,7 +65,18 @@ export function getMenuList(userRole?: UserRoleEnum): Promise<IModule[]> {
{ id: 53, modId: 1111, name: "操作日志", page: "/oplog", 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: 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] },
],
},
];
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 {
position: relative;
width: 60px;
height: 90px;
padding-top: 60px;
background-image: url(../image/loading.gif);
background-size: 60px 60px;
background-position: top center;
background-repeat: no-repeat;
border: none;
color: #577afb;
color: var(--brand);
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 {
margin-right: 4px;
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>
+6
View File
@@ -215,6 +215,11 @@ function getNodeById(id: number) {
return refMyTree.value?.getNodeById(id);
}
/** 在指定节点后追加自定义控件(见 MyTree.attachNodeControl) */
function attachNodeControl(id: number, className: string, html: string) {
return refMyTree.value?.attachNodeControl(id, className, html);
}
function getCheckedNodes() {
return refMyTree.value?.getCheckedNodes() || [];
}
@@ -257,6 +262,7 @@ defineExpose({
removeNode,
renameNode,
getNodeById,
attachNodeControl,
getCheckedNodes,
checkNodeById,
addNote,
+36 -8
View File
@@ -1,26 +1,36 @@
<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>
<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>
<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>
<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>
<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>
</template>
</Modal>
</template>
<script lang="ts" setup>
import { ref, onMounted } from "vue";
import { Modal, Button, Alert, Icon } from "view-ui-plus";
import { ref, computed, onMounted } from "vue";
import { Modal, Button, Alert, Icon, Input } from "view-ui-plus";
const isModal = ref(false),
okLoading = ref(false),
cancleLoading = ref(false);
cancleLoading = ref(false),
inputName = ref("");
const props = withDefaults(
defineProps<{
@@ -30,6 +40,8 @@ const props = withDefaults(
isLoading?: boolean;
confirmText?: string;
closeText?: string;
confirmName?: string;
danger?: boolean;
confirmFn: any;
closeFn: any;
destroy: Function;
@@ -39,9 +51,13 @@ const props = withDefaults(
isLoading: true,
confirmText: "确认",
closeText: "取消",
confirmName: "",
danger: false,
}
);
const nameMatched = computed(() => !props.confirmName || inputName.value.trim() === props.confirmName);
onMounted(function () {
setTimeout(() => {
isModal.value = true;
@@ -49,6 +65,7 @@ onMounted(function () {
});
const onOkClick = async () => {
if (!nameMatched.value) return;
if (!!props.isLoading) okLoading.value = true;
const { onOk, reslove } = props.confirmFn;
await Promise.resolve(onOk());
@@ -73,4 +90,15 @@ const showChange = (state: boolean) => {
};
</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 { createApp } from "vue";
export default ({ title, msg, info, confirmText, closeText, isLoading, onOk, onCancel }: MyConfirm) => {
return new Promise<boolean>((reslove, reject) => {
export default ({ title, msg, info, confirmText, closeText, isLoading, confirmName, danger, onOk, onCancel }: MyConfirm) => {
return new Promise<boolean>((reslove, _reject) => {
const instance = createApp(confirm, {
title,
msg,
@@ -11,6 +11,8 @@ export default ({ title, msg, info, confirmText, closeText, isLoading, onOk, onC
confirmText,
closeText,
isLoading,
confirmName,
danger,
confirmFn: {
onOk,
reslove,
+27
View File
@@ -222,6 +222,33 @@ export default {
if (on) a.addClass('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() {
(this.highlightNodes || []).forEach(n => this.setHighlight(n, false));
this.highlightNodes = [];
+1 -1
View File
@@ -3,7 +3,7 @@
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
class="link-table table-bottom-box" @on-selection-change="selectChange">
<template #loading>
<div class="table-spin-load">加载中</div>
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
</template>
</Table>
+24 -7
View File
@@ -1,16 +1,18 @@
<template>
<div class="table-region" ref="regionRef">
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
class="link-table table-bottom-box" @on-selection-change="selectChange">
<template #loading>
<div class="table-spin-load">加载中</div>
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
</template>
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" />
</template>
</Table>
</div>
<div class="page-panel">
<div class="fl align-middle">
<div class="fl ml-15 align-middle">
<slot :checkList="selectList" />
</div>
@@ -21,8 +23,8 @@
</template>
<script setup lang="ts">
import { computed, ref, useSlots } from 'vue'
import Utils from '@/utils'
import { computed, ref, useSlots, watch } from 'vue'
import { useElementHeight } from '@/utils/hook/useElementHeight'
import { OperatorEnum } from '@/types'
interface Props {
@@ -44,7 +46,10 @@ const emit = defineEmits<{
}>()
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 selectList = ref<number[]>([])
@@ -68,18 +73,30 @@ const {
total: res => props.formatResponse(res).total,
data: res => props.formatResponse(res).list,
initialPage: 1,
initialPageSize: 10,
// 页长记忆(bsch 应用页规范 §3:customPageCount localStorage)
initialPageSize: parseInt(localStorage.getItem('customPageCount') || '10') || 10,
immediate: props.autoLoad,
}
)
// 数据重载后清空勾选,避免批量操作用到旧选中行
watch(resp, () => {
if (selectList.value.length) {
selectList.value = []
emit('selection-change', selectList.value)
}
})
const list = computed(() => {
if (!resp.value) return []
return props.dataFilter ? props.dataFilter(resp.value) : resp.value
})
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() {
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>
+29 -16
View File
@@ -1,10 +1,12 @@
<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 { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
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 }>();
const reload: Function = inject("reload") || new Function("console.error('empty')");
@@ -12,6 +14,27 @@ const appPanel = ref();
let route = useRoute();
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),
currPath = ref<string>(""),
navPath = ref<any[]>([]);
@@ -138,7 +161,8 @@ const linkMenu = (page: string) => {
};
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 = () => {
let url = window.location.href;
@@ -165,16 +189,13 @@ function linkHomePage() {
<template>
<div class="layout-wrap">
<header class="header-container fix">
<div class="fl align-middle icon-box">
<div title="首页" class="icon-btn home-btn" @click="linkHomePage">
<SvgIcon name="shouye" :size="22" color="#fff" />
</div>
</div>
<!-- 云海 logo 徽标(结构与 Notice layout 对齐,皮肤见通道 base.layout.css .logo-badge) -->
<span class="logo-badge fl"><img :src="logoUrl" alt="云海" @error="onLogoError" /></span>
<div class="delimiter fl align-middle"></div>
<h4 class="fl align-middle">{{ appName }}</h4>
<div class="school-bg"></div>
<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">
{{ userInfo.name }}
</a>
@@ -269,12 +290,4 @@ function linkHomePage() {
.header-aside .logout svg {
display: block;
}
.home-btn {
display: flex;
align-items: center;
justify-content: center;
}
.home-btn 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 "@/assets/styles/main.scss";
// 本地化的布局/主题样式(原远程 base.layout.beta / _iview_override / default-theme 合并)
import "@/assets/styles/layout.css";
// 布局/主题/图标(base.layout.css + iview.override.css + icons.css)由中央静态通道下发:
// index.html 的 yh-theme-init.js 在 DOMContentLoaded 后按 manifest 版本追加 <link>,
// 天然排在本行组件库样式与 main.scss 之后(原本地 layout.css 是通道旧版全量拷贝,已删)
//导入自定义指令
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 OapiLogList from "@/views/oapilog/OapiLogList.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";
// 管理端四级访问控制:meta.roles 为空/未声明 = 登录即可访问
@@ -106,18 +106,21 @@ const routes: RouteRecordRaw[] = [
component: PermissionView,
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",
name: "PushLog",
component: PushLog,
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",
+4
View File
@@ -105,6 +105,10 @@ export interface MyConfirm {
isLoading?: boolean;
confirmText?: string;
closeText?: string;
/** 输名确认:值与 msg 对象名一致才允许点确认键(危险操作删除模,对齐 projman) */
confirmName?: string;
/** 红色确认键(危险操作) */
danger?: boolean;
onOk?: 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 实例 */
export const alovaInstance = createBschAlova(baseUrl, {
+2 -1
View File
@@ -19,7 +19,8 @@ function closeLoading() {
}
// 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 实例 */
export const alovaInstance = createBschAlova(baseUrl, {
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;
}
+12 -33
View File
@@ -1,5 +1,5 @@
import { IObj, UseTableSetting } from "@/types/Basics";
import { resolveComponent } from "vue";
import { actionIcon, actionText } from "@/utils/tableActions";
export default class Utils {
/**
* 格式化日期
@@ -90,7 +90,8 @@ export default class Utils {
}
/**
* iview Tooltip封装
* iview Tooltip封装(旧 API,薄封装转调 tableActions.actionIcon,签名不变)
* 2026-10-10 起 D1 修正:禁用态 onClick 置 null(原先禁用仍可点)。
*/
public static operatingHandle(
h: any,
@@ -99,30 +100,10 @@ export default class Utils {
callback: Function,
disabled: boolean = false,
) {
if (disabled) iconClass += " disabled";
return h(
resolveComponent("Tooltip"),
{
content: info,
placement: "bottom",
transfer: true,
},
{
default: () => [
h(
"div",
{
class: `operating-btn iconfont ${iconClass}`,
onClick: callback,
},
null,
),
],
},
);
return actionIcon(h, { key: info, label: info, icon: iconClass, on: () => callback(), disabled });
}
/** 旧 API,薄封装转调 tableActions.actionText(签名不变;第三参仅识别 text-error → danger) */
public static operatingTextHandle(
h: any,
info: string,
@@ -130,15 +111,13 @@ export default class Utils {
callback: Function,
disabled: boolean = false,
) {
if (disabled) className += " disabled";
return h(
"div",
{
class: `operating-link ${className}`,
onClick: disabled ? null : callback,
},
info,
);
return actionText(h, {
key: info,
label: info,
danger: (className || "").includes("text-error"),
on: () => callback(),
disabled,
});
}
/**
+160
View File
@@ -0,0 +1,160 @@
/**
* 表格操作列统一原子(方案:outputs/table-action-buttons/操作列统一方案-20261010.md)
*
* 三条变化轴各自独立,每加一种形态 = 加 1 个渲染器 + RENDERERS 表加 1 行,不动既有分支:
* 轴1 形态 actionIcon / actionText / actionMore —— 只画节点,零策略
* 轴2 动作集 normalizeActions —— 纯数据:when 过滤 / disabled 归一 / danger 沉底
* 轴3 溢出 splitActions —— 纯函数:直出与「更多」分档,零 DOM 可单测
* 组装 actionCell / actionColumn —— 唯一懂 DOM 结构与表格列
*
* 形态由页面【显式声明】(mode: "icon" | "text",缺省 text),不按动作数自动切换——
* 工程间风格区分是既定口径(Notice=图标钮 / UserBase=文字链),各自恒定不漂移。
* 「更多▾」只是长尾收纳(动作数 ≥ maxInline+2 时启用),不改变形态、不分档。
* 菜单项纯文字不带图标(动作一多找不到贴切图标,文字更准);transfer 到 body,
* 避免 fixed/sticky 操作列裁剪与层叠上下文遮盖(原型已实证)。
*/
import { h, resolveComponent } from "vue";
export interface ActionItem {
/** 稳定标识:溢出分桶按 key 去重,必填 */
key: string;
label: string;
/** 点击回调,normalizeActions 阶段闭包绑定行数据 */
on: (row: any) => void;
/** iconfont 类名(仅 mode:"icon" 时消费;通道 icons.css 全集见 YschTheme) */
icon?: string;
/** 危险动作:文字转红(text-error)、恒沉底、「更多」内分隔线隔离 */
danger?: boolean;
/** 返回 false → 该行不渲染此动作(决定「有没有」) */
when?: (row: any) => boolean;
/** 直接置灰 */
disabled?: boolean;
/** 按行判定置灰(决定「能不能点」),disabled 显式值优先 */
disabledWhen?: (row: any) => boolean;
}
export interface ActionCellOpts {
/** 形态显式声明:icon=图标圆钮 / text=文字链,缺省 text;不按动作数自动切换 */
mode?: "icon" | "text";
/** 超额动作收进「更多」,默认开(防 5+ 动作页面忘传爆宽) */
collapsible?: boolean;
/** 直出上限,默认 2;n ≤ maxInline+1 时全直出(3 个动作不进更多) */
maxInline?: number;
/** 列宽;缺省 icon = n*40+40 / text = 180 */
width?: number;
align?: "center" | "left" | "right";
/** 缺省不固定列(与存量页面一致);需要吸右显式传 "right" */
fixed?: "right" | "left" | false;
}
/* ============ 轴 1:渲染器(只画节点,零策略) ============ */
/** 图标圆钮:Tooltip > div.operating-btn(沿用通道既有 class,主题层零改动) */
export function actionIcon(h: any, item: ActionItem) {
return h(
resolveComponent("Tooltip"),
{ content: item.label, placement: "bottom", transfer: true },
{
default: () => [
h("div", {
class: `operating-btn iconfont ${item.icon}${item.disabled ? " disabled" : ""}`,
onClick: item.disabled ? null : item.on,
}),
],
},
);
}
/** 文字链:danger 输出既有 text-error 类对接主题红色 */
export function actionText(h: any, item: ActionItem) {
return h(
"div",
{
class: `operating-link${item.danger ? " text-error" : ""}${item.disabled ? " disabled" : ""}`,
onClick: item.disabled ? null : item.on,
},
item.label,
);
}
/** 「更多▾」+ 下拉菜单:iView Dropdown transfer 模式,菜单挂 body */
export function actionMore(h: any, items: ActionItem[]) {
return h(
resolveComponent("Dropdown"),
{ class: "op-more", placement: "bottom-end", transfer: true },
{
default: () => [
h("div", { class: "operating-link op-more-label" }, [
"更多",
h(resolveComponent("Icon"), { type: "ios-arrow-down", class: "op-more-arrow" }),
]),
],
list: () => [
h(resolveComponent("DropdownMenu"), {}, {
default: () => items.map((it) =>
h(
resolveComponent("DropdownItem"),
{
key: it.key,
divided: !!(it.danger && items.length > 1),
disabled: !!it.disabled,
class: it.danger ? "op-item-danger" : "",
onClick: () => it.on(),
},
{ default: () => it.label },
),
),
}),
],
},
);
}
/* ============ 轴 2:归一化(纯数据) ============ */
export function normalizeActions(items: ActionItem[], row: any): ActionItem[] {
return items
.filter((it) => (it.when ? it.when(row) : true))
.map((it) => ({
...it,
disabled: it.disabled ?? (it.disabledWhen ? it.disabledWhen(row) : false),
on: () => it.on(row),
}))
.sort((a, b) => Number(!!a.danger) - Number(!!b.danger)); // danger 恒沉底(稳定排序)
}
/* ============ 轴 3:溢出策略(纯函数,可单测、零 DOM) ============ */
export function splitActions(items: ActionItem[], maxInline = 2): { inline: ActionItem[]; overflow: ActionItem[] } {
if (items.length <= maxInline + 1) return { inline: items, overflow: [] };
const inline = items.filter((it) => !it.danger).slice(0, maxInline);
const inlineKeys = new Set(inline.map((it) => it.key));
return { inline, overflow: items.filter((it) => !inlineKeys.has(it.key)) };
}
/* ============ 组装层 ============ */
const RENDERERS: Record<string, (h: any, item: ActionItem) => any> = { icon: actionIcon, text: actionText };
export function actionCell(h: any, items: ActionItem[], opts: ActionCellOpts = {}) {
const mode = opts.mode ?? "text";
const { inline, overflow } = (opts.collapsible ?? true)
? splitActions(items, opts.maxInline ?? 2)
: { inline: items, overflow: [] };
const render = RENDERERS[mode] || actionText;
const children = inline.map((it) => render(h, it));
if (overflow.length) children.push(actionMore(h, overflow));
return h("div", { class: "op-cell" }, children); // flex 横排容器,见 main.scss
}
/** 产出可直接塞进 columns 的操作列对象;列宽按「声明数」估算,when 过滤后只宽不窄 */
export function actionColumn(items: ActionItem[], opts: ActionCellOpts = {}) {
const mode = opts.mode ?? "text";
return {
title: "操作",
key: "action",
width: opts.width ?? (mode === "icon" ? items.length * 40 + 40 : 180),
align: opts.align ?? "center",
...(opts.fixed === undefined ? {} : { fixed: opts.fixed === false ? undefined : opts.fixed }),
render: (h: any, p: any) => actionCell(h, normalizeActions(items, p.row), opts),
};
}
+2 -2
View File
@@ -372,14 +372,14 @@ onMounted(async () => {
}
.school-name {
color: #2d8cf0;
color: var(--brand);
font-weight: 600;
}
.authz-panel {
position: relative;
flex: 1;
min-height: 320px;
min-height: 0;
padding: 12px 16px;
background: #fff;
border-radius: 4px;
+90 -19
View File
@@ -38,17 +38,19 @@
<Icon class="collapse-icon"
: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-count">{{ item.treeData.length }}个应用</span>
<span class="inherit-cap">子组继承</span>
<span class="cat-spacer"></span>
<!-- 开关外层 @click.stop:否则点击会冒泡到卡片头部触发折叠 -->
<span class="expand-all" @click.stop>
<span class="expand-all-label">全部展开</span>
<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 v-show="!collapsedMap[String(item.cat.id)]" class="cat-card-body">
<MyTree :treeData="item.treeData" :setting="treeSetting" :autoClick="false" :isCheck="true"
:expandAll="!!expandAllMap[String(item.cat.id)]" @on-tree-check="onTreeCheck" />
<!-- 「N个应用」计数:绝对定位到整个卡片的右下角(与子组继承复选框同列) -->
<span class="cat-count">{{ item.treeData.length }}个应用</span>
</div>
</div>
</div>
@@ -88,6 +90,10 @@ type AppItem = {
id?: number;
name?: string;
sortNo?: number;
/** 应用级勾选态:仅当 modules 为空时有意义(对应后端 module_id=0 的整应用授权记录) */
checked?: boolean;
/** 应用级子组继承:仅当 modules 为空时有意义 */
inheritFlag?: boolean;
modules?: AppModuleVo[];
};
@@ -219,19 +225,22 @@ function countTotalModules(nodes: AppModuleVo[]): number {
function isAppChecked(app: AppItem): boolean {
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 {
const modules = app.modules || [];
if (modules.length === 0) return false;
if (modules.length === 0) return !!app.inheritFlag;
return modules.every((m) => isModuleAllInherit(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));
}
function isModuleAllInherit(node: AppModuleVo): boolean {
@@ -246,7 +255,8 @@ function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
id: `mod_${m.id}`,
name: String(m.name || ""),
open: true,
checked: !!m.checked,
// 父模块节点沿用「有子节点选中即选中」的展示口径,与顶层应用节点一致
checked: isModuleAnyChecked(m),
inheritFlag: !!m.inheritFlag,
nodeType: "module",
rawId: Number(m.id),
@@ -421,6 +431,10 @@ function findLocalApp(appId: number): AppItem | null {
function setLocalAppInherit(appId: number, inherit: boolean) {
const app = findLocalApp(appId);
if (!app) return;
if ((app.modules || []).length === 0) {
app.inheritFlag = inherit;
return;
}
walkModules(app.modules || [], (n) => {
if (n.checked) n.inheritFlag = inherit;
});
@@ -451,6 +465,33 @@ async function onTreeCheck(node: any) {
return;
}
// 无子模块应用(如「一周安排」):授权粒度即整个应用,落 moduleId=0 的整应用记录。
// 这类应用没有可 diff 的模块明细,必须单独走应用级提交,否则勾选只是个视觉状态、刷新即失效。
const appModules = app.modules || [];
if (appModules.length === 0) {
const checked = !!node.checked;
try {
if (checked !== !!app.checked) {
app.checked = checked;
await Apis.GroupAuthz.setGroupModule({
data: [{
groupId,
appId: node.appId,
moduleId: 0,
mode: checked ? addMode.value : deleteMode.value,
isAll: 0,
}],
cacheFor: null,
});
}
syncInheritDoms(treeObj);
} catch (e: any) {
console.warn(e?.message || "操作失败");
await reloadCurrent();
}
return;
}
// 级联前快照:用于计算本次实际变化的模块明细
const before = new Map<number, boolean>();
walkModules(app.modules, (m) => before.set(Number(m.id), !!m.checked));
@@ -601,14 +642,14 @@ onMounted(async () => {
}
.group-name {
color: #2d8cf0;
color: var(--brand);
font-weight: 600;
}
.authz-panel {
position: relative;
flex: 1;
min-height: 320px;
min-height: 0;
padding: 12px 16px;
background: #fff;
border-radius: 4px;
@@ -662,24 +703,41 @@ onMounted(async () => {
font-size: 13px;
color: #333;
margin-left: 6px;
/* 「N个应用」计数已绝对定位脱离主行,主行只剩分类名 + 全部展开 + 子组继承 */
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cat-count {
/* 计数贴在整张卡片右下角:与右侧「子组继承」复选框同列,脱离 tree 的文档流 */
position: absolute;
right: 14px;
bottom: 6px;
color: #999;
font-size: 12px;
margin-left: 8px;
flex: 1;
line-height: 18px;
white-space: nowrap;
}
/* 弹性空隙:把「全部展开 / 子组继承」推到卡片头右端,与分类名拉开距离 */
.cat-spacer {
flex: 1 1 0;
min-width: 0;
}
.inherit-cap {
font-size: 12px;
color: #999;
flex-shrink: 0;
margin-right: 6px;
margin-left: 10px;
padding: 0 6px;
line-height: 18px;
background: #f0f2f5;
border-radius: 2px;
cursor: default;
}
.expand-all {
@@ -687,6 +745,7 @@ onMounted(async () => {
align-items: center;
flex-shrink: 0;
gap: 4px;
margin-left: 12px;
}
.expand-all-label {
@@ -696,8 +755,9 @@ onMounted(async () => {
}
.cat-card-body {
padding: 6px 8px;
/* 子组继承复选框的绝对定位锚点:保证所有层级节点的复选框右对齐成一列 */
/* 底部多留一行:容纳右下角的「N个应用」计数,避免压住最后一行节点 */
padding: 6px 8px 24px;
/* 子组继承复选框 / 计数角标的绝对定位锚点:保证两者右对齐成一列 */
position: relative;
}
@@ -708,13 +768,22 @@ onMounted(async () => {
}
.open-link {
color: #2d8cf0;
color: var(--brand);
&:hover {
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 节点行尾的"子组继承"复选框:
float 会跟随节点缩进产生阶梯,改为绝对定位锚定卡片体右边界,所有层级右对齐成一列 */
:deep(.inherit-wrap) {
@@ -734,7 +803,9 @@ onMounted(async () => {
}
:deep(.ztree li a) {
max-width: calc(100% - 90px);
/* 子组继承复选框仅悬停时由 addHoverDom 注入(绝对定位 right:14,占位约 30px),
常态无需预留 90px——收窄预留,消除「明明有空间却截断成 ...」 */
max-width: calc(100% - 36px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+37 -2
View File
@@ -62,11 +62,19 @@
</div>
<!-- 右列:已授权应用(原有);支持三种视图:用户聚合 / 部门树节点 / 角色树节点 -->
<div class="perm-main">
<div class="side-title">{{ permMainTitle }}</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 v-if="permLoading" class="loading-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>
@@ -156,6 +164,9 @@ const permScope = ref<{ type: "user" | "group" | "role"; id?: number; label: str
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})`;
@@ -571,6 +582,30 @@ function formatModules(modules: ModuleNode[]): any[] {
font-size: 13px;
background: #fafafa;
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 {
+5 -1
View File
@@ -22,7 +22,7 @@ let onSuccessCallback: (() => void) | null = null;
// useForm 托管提交
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
(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(() => {
@@ -50,11 +50,15 @@ async function handleSubmit(formData: Record<string, any>) {
name: formData.name,
categoryId: formData.categoryId,
desc: formData.desc,
appCode: formData.appCode,
appNature: formData.appNature,
clients: formData.clients,
pcUrl: formData.pcUrl,
pcIcon: formData.pcIcon,
appUrl: formData.appUrl,
appIcon: formData.appIcon,
iconKey: formData.iconKey,
iconColor: formData.iconColor,
officialAccountUrl: formData.officialAccountUrl,
dingtalkUrl: formData.dingtalkUrl,
};
+155 -15
View File
@@ -33,6 +33,31 @@
</Col>
</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(编辑模式且非学校模式才显示) -->
<Row v-if="isEdit && !schoolMode">
<Col span="12">
@@ -50,8 +75,8 @@
</Col>
</Row>
<!-- 第四行:应用前端类型(tag 多选;学校模式隐藏) -->
<Row v-if="!schoolMode">
<!-- 第四行:应用前端类型(tag 多选;学校模式/基石应用隐藏——基石不进门户,无投放载体) -->
<Row v-if="!schoolMode && !natureBase">
<Col span="24">
<FormItem label="应用前端类型:">
<Select v-model="form.clients" multiple transfer placeholder="请选择前端类型" class="client-select">
@@ -61,14 +86,14 @@
</Col>
</Row>
<!-- 第五行:Pc应用地址 / Pc应用图标(学校模式仅保留 Pc应用图标) -->
<Row>
<!-- 第五行:Pc应用地址 / 应用图标(平台模式=AppIcon 矢量选择器;学校模式保留位图上传——学校覆盖线只收 pcIcon) -->
<Row v-if="!natureBase">
<Col span="12" v-if="!schoolMode">
<FormItem label="Pc应用地址:">
<Input v-model="form.pcUrl" placeholder="请输入Pc应用地址" />
</FormItem>
</Col>
<Col span="12">
<Col span="12" v-if="schoolMode">
<FormItem label="Pc应用图标:">
<Upload :on-success="(res: any) => onUploadSuccess(res, 'pcIcon')" :show-upload-list="false"
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
@@ -84,35 +109,73 @@
</Upload>
</FormItem>
</Col>
<Col span="12" v-else>
<FormItem label="应用图标:">
<AppIconPicker v-model="form.iconKey" v-model:color="form.iconColor" />
</FormItem>
</Col>
</Row>
<!-- 第六行:App接口地址 / App应用图标(学校模式隐藏) -->
<Row v-if="!schoolMode">
<!-- 第六行:App接口地址(学校模式/基石应用隐藏;App位图图标移入下方高级折叠区) -->
<Row v-if="!schoolMode && !natureBase">
<Col span="12">
<FormItem label="App接口地址:">
<Input v-model="form.appUrl" placeholder="请输入App接口地址" />
</FormItem>
</Col>
<Col span="12">
<FormItem label="App应用图标:">
<Upload :on-success="(res: any) => onUploadSuccess(res, 'appIcon')" :show-upload-list="false"
</Row>
<!-- 位图上传(高级 · 遗留通道):存量 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"
:with-credentials="true" :headers="{ yschapirequest: 1 }">
<div v-if="!form.appIcon" 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.appIcon = null" class="cloase-btn" name="widget-close" size="18"
color="#fff" />
<SvgIcon @click.stop="form.appIcon = null" class="cloase-btn"
name="widget-close" size="18" color="#fff" />
<img :src="previewUrl(form.appIcon)" alt="" />
</div>
</Upload>
</FormItem>
<div class="bitmap-hint">存量移动端位图;矢量图标优先生效。</div>
</div>
</div>
</template>
</Panel>
</Collapse>
</Col>
</Row>
<!-- 第七行:公众号应用地址 / 钉钉应用地址(学校模式隐藏) -->
<Row v-if="!schoolMode">
<!-- 第七行:公众号应用地址 / 钉钉应用地址(学校模式/基石应用隐藏) -->
<Row v-if="!schoolMode && !natureBase">
<Col span="12">
<FormItem label="公众号应用地址:">
<Input v-model="form.officialAccountUrl" placeholder="请输入公众号应用地址" />
@@ -135,6 +198,7 @@
<script setup lang="ts">
import SvgIcon from "@/components/SvgIcon.vue";
import AppIconPicker from "@/components/AppIconPicker.vue";
import { computed, reactive, watch } from "vue";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
@@ -173,11 +237,16 @@ const defaultForm = (): Record<string, any> => ({
desc: '',
appKey: null,
appSecret: '',
appCode: '',
appNature: 'BUSINESS',
clients: [] as number[],
pcUrl: '',
pcIcon: '',
appUrl: '',
appIcon: '',
// 矢量图标(AppIcon 资产库):iconKey 空 = 不使用矢量,端侧走 关键词→位图→首字 原回退链
iconKey: '',
iconColor: '',
officialAccountUrl: '',
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):
// 1=Pc浏览器 2=移动App 8=微信公众号 32=钉钉;公众号/钉钉地址见表单第七行
@@ -221,12 +301,26 @@ const clientOptions = [
{ value: 32, label: '钉钉' },
];
/* ---------- 应用性质 Switch(开=BASE 基石 / 关=BUSINESS 业务) ---------- */
const natureBase = computed({
get: () => form.appNature === 'BASE',
set: (val: boolean) => { form.appNature = val ? 'BASE' : 'BUSINESS'; },
});
/* ---------- 校验规则(学校模式不校验 categoryId——类型字段不展示且不可改) ---------- */
const rules = computed(() => ({
name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
...(props.schoolMode ? {} : {
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;
}
}
/* 位图上传(高级折叠区):并排两枚上传位 */
.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>
+54 -58
View File
@@ -31,7 +31,7 @@
style="width:220px" @on-search="onSearch" @on-clear="onSearch" />
</div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columnsComp" :tableHeight="tableHeight" />
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columnsComp" />
</div>
<!-- 容器组件:新增 / 编辑 -->
@@ -51,7 +51,6 @@
import { ref, computed } from "vue";
defineOptions({ name: "AppList" });
import { useRouter } from "vue-router";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import MyConfirm from "@/components/MyConfirm";
import AddAppContainer from "./AddAppContainer.vue";
import EditAppContainer from "./EditAppContainer.vue";
@@ -60,9 +59,9 @@ import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import { OperatorEnum } from '@/types';
import AppSort from './AppSort.vue';
import { useAppSchoolScope } from "@/utils/hook/useAppSchoolScope";
import { actionColumn } from "@/utils/tableActions";
const router = useRouter();
const { $utils } = useCurrentInstance();
// 学校管理员上下文:多校显示切换下拉,覆盖仅对所选学校生效(默认并集,写操作须先选定学校)
const { isSchoolMode, multiSchool, schoolList, currentSchoolId, requireSchool } = useAppSchoolScope();
@@ -146,10 +145,6 @@ function onSortSaved() {
tableRef.value?.reloadData(OperatorEnum.sel);
}
const tableHeight = ref(
$utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true })
);
/* ---------------- 表格固定列(不含“应用类型”列) ---------------- */
const columns = [
{ type: "index", title: "序号", width: 70 },
@@ -169,21 +164,46 @@ const columns = [
},
},
{
title: "操作",
key: "action",
align: 'center',
width: 280,
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;
}
title: "性质",
key: "appNature",
width: 80,
render: (h: any, { row }: any) => h("span", row.appNature === "BASE" ? "基石" : "业务"),
},
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;
}
/* ---------------- 删除应用 ---------------- */
/* ---------------- 删除应用(删除模:输名确认 + 红色确认键,对齐 projman;基石应用同样可删) ---------------- */
async function handleDelete(row: any) {
const name = row.name || String(row.id);
await MyConfirm({
msg: `确定删除应用「${row.name || row.id}」吗?`,
info: "删除后不可恢复!",
title: "删除应用",
msg: `确定删除应用「${name}」吗?`,
info: "删除后不可恢复,关联就绪资源需手动清理!"
+ (row.appNature === "BASE" ? "(基石应用:请先确认无平台组件仍依赖其 appKey 签名身份)" : ""),
confirmName: name,
danger: true,
confirmText: "删除",
async onOk() {
try {
await Apis.AppSettings.deleteApp({ pathParams: { id: row.id } });
Message.success('删除成功!');
tableRef.value?.reloadData(OperatorEnum.del);
} catch (e: any) {
console.warn(e?.message || '删除失败');
Message.error(e?.message || '删除失败');
}
},
});
@@ -273,7 +299,6 @@ const goModuleManage = (row: any) => {
flex-direction: column;
overflow: hidden;
padding: 0;
width: 180px;
}
.school-select {
@@ -295,7 +320,7 @@ const goModuleManage = (row: any) => {
border-top: 1px solid #eeeeee;
.ivu-btn-text {
color: #577afb;
color: var(--brand);
}
}
@@ -316,37 +341,8 @@ const goModuleManage = (row: any) => {
}
li.active {
color: #577afb;
border-color: #577afb;
color: var(--brand);
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>
+11 -2
View File
@@ -34,7 +34,7 @@ const { form, loading: submitting, send: sendForm, onSuccess, onError } = useFor
data: dto,
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(() => {
@@ -67,11 +67,16 @@ function open(id: number, row: any, categories: any[], onSuccess?: () => void) {
async function handleSubmit(formData: Record<string, any>) {
if (!currentId.value) return;
if (isSchoolMode) {
// 学校模式:仅 名称/描述/Pc图标 三字段写学校覆盖表,其余字段一律不提交
// 学校模式:仅 名称/描述/Pc图标 三字段写学校覆盖表(后端 editAppForSchool 只读这三个);
// categoryId/appCode/appNature 原样回传仅为满足平台级 Update 校验(后端不落平台表),缺失会 400;
// iconKey/iconColor 不送键(后端 null=不改——学校线不动平台矢量配置)
form.value = {
id: currentId.value,
name: formData.name,
categoryId: formData.categoryId,
desc: formData.desc,
appCode: formData.appCode,
appNature: formData.appNature,
pcIcon: formData.pcIcon,
};
} else {
@@ -80,11 +85,15 @@ async function handleSubmit(formData: Record<string, any>) {
name: formData.name,
categoryId: formData.categoryId,
desc: formData.desc,
appCode: formData.appCode,
appNature: formData.appNature,
clients: formData.clients,
pcUrl: formData.pcUrl,
pcIcon: formData.pcIcon,
appUrl: formData.appUrl,
appIcon: formData.appIcon,
iconKey: formData.iconKey,
iconColor: formData.iconColor,
officialAccountUrl: formData.officialAccountUrl,
dingtalkUrl: formData.dingtalkUrl,
};
+23 -26
View File
@@ -27,14 +27,14 @@
<Input placeholder="请输入模块名称" v-model="form.name" />
</FormItem>
<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 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 label="排序号:" prop="sortNo">
<InputNumber :style="{ width: '100%' }" placeholder="请输入排序号" v-model="form.sortNo" />
@@ -54,9 +54,7 @@ import { useRoute, useRouter } from "vue-router";
import { debounce } from "lodash";
import SvgIcon from "@/components/SvgIcon.vue";
import MyConfirm from "@/components/MyConfirm";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
const { $utils } = useCurrentInstance();
import { actionColumn } from "@/utils/tableActions";
const route = useRoute();
const router = useRouter();
@@ -116,22 +114,18 @@ const columns = [
{ title: "ID", key: "customId", width: 120, align: "center", render: (h: any, { row }: any) => h("span", row.customId || "-") },
{ title: "路径", 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 ?? "-") },
// UserBase 恒文字链;3 动作 ≤ maxInline+1 → 全直出(与改前形态一致),列宽 220 → 180
actionColumn([
{ key: "edit", label: "编辑", icon: "icon-edit", on: (r: any) => handleEdit(r) },
{
title: "操作",
key: "action",
width: 220,
align: "center",
render: (h: any, { row }: any) => {
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: "add",
label: "添加子模块",
icon: "icon-tianjia",
on: (r: any) => handleAddChild(r),
when: (r: any) => r.level < 5,
},
{ key: "del", label: "删除", icon: "icon-ashbin", on: (r: any) => handleDelete(r), danger: true },
], { mode: "text", width: 180 }),
];
/* ---------------- 弹窗状态 ---------------- */
@@ -159,13 +153,16 @@ const form = ref<{
sortNo: null,
});
const ruleValidate = ref({
const openingIsChild = ref(false);
/* 子模块双定位:树节点(带路径/编码)或纯按钮权限(两者皆可空)→ 仅根模块必填 */
const ruleValidate = computed(() => ({
name: [{ required: true, message: "请输入模块名称", trigger: "blur" }],
...(openingIsChild.value ? {} : {
path: [{ required: true, message: "请输入模块路径", trigger: "blur" }],
customId: [{ required: true, type: "number", message: "请输入自定义ID", trigger: "change" }],
});
const openingIsChild = ref(false);
}),
}));
const modalTitle = computed(() => {
if (currentId.value != null) return '编辑模块';
return openingIsChild.value ? '新增子模块' : '新增模块';
@@ -264,7 +261,7 @@ async function onRawSave() {
data: {
appId: appId.value,
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),
parentId: form.value.parentId ?? undefined,
@@ -278,8 +275,8 @@ async function onRawSave() {
id: currentId.value,
appId: appId.value,
name: form.value.name,
path: form.value.path,
// 后端 edit 逐字段整体赋值,字段缺省即置空 → 清空图标能生效
path: form.value.path?.trim() || undefined,
// 后端 edit 逐字段整体赋值,字段缺省即置空 → 清空图标/路径能生效
icon: form.value.icon?.trim() || undefined,
customId: form.value.customId == null ? '' : String(form.value.customId),
parentId: form.value.parentId ?? undefined,
+200
View File
@@ -0,0 +1,200 @@
<!-- 自定义部门卡片(已被 CustomDeptPanel.vue 的表格取代,2026-10-23)。保留文件仅作参考,无任何引用,可安全删除。 -->
<template>
<article class="cdept-card" :class="{ 'is-off': !dept.enabled }">
<div class="row-1">
<span class="status-dot" :class="{ 'is-off': !dept.enabled }"></span>
<h3 class="card-name" :title="dept.name">{{ dept.name }}</h3>
<span class="sw" :class="{ 'is-off': !dept.enabled }" :title="dept.enabled ? '点击停用' : '点击启用'"
@click="emit('toggle', dept)"></span>
</div>
<div class="row-2">
<span class="sub">{{ memberText }}</span>
<span class="ops">
<i class="op" :class="{ 'is-disabled': !canMoveUp }" title="上移" @click="emit('moveUp')">↑</i>
<i class="op" :class="{ 'is-disabled': !canMoveDown }" title="下移" @click="emit('moveDown')">↓</i>
<a class="link" @click="emit('members', dept)">成员</a>
<a class="link" @click="emit('edit', dept)">编辑</a>
<a class="link danger" @click="emit('remove', dept)">删除</a>
</span>
</div>
</article>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import type { CustomDepartmentVo } from "@/api/globals";
/**
* CUSTOM 部门卡片:与 SYSTEM 的 DeptCard 同款外观,但字段来自 ub_custom_department。
* 停用在管理端照常可见(灰态),软删的部门由后端列表过滤、不会走到这里。
*/
const props = defineProps<{
dept: CustomDepartmentVo;
canMoveUp?: boolean;
canMoveDown?: boolean;
}>();
const emit = defineEmits<{
(e: "toggle", dept: CustomDepartmentVo): void;
(e: "edit", dept: CustomDepartmentVo): void;
(e: "remove", dept: CustomDepartmentVo): void;
(e: "members", dept: CustomDepartmentVo): void;
(e: "moveUp"): void;
(e: "moveDown"): void;
}>();
const memberText = computed(() => {
const count = Number(props.dept?.memberCount ?? 0);
if (!props.dept?.enabled) return count ? `已停用 · ${count} 人` : "已停用 · 无成员";
return count ? `${count} 人` : "无成员(可先建部门再配人)";
});
</script>
<style scoped lang="scss">
.cdept-card {
display: flex;
flex-direction: column;
gap: 9px;
padding: 13px 14px;
background: var(--panel, #fff);
border: 1px solid var(--line, #e9edf5);
border-radius: var(--r, 10px);
box-shadow: 0 1px 2px rgba(30, 38, 53, .03);
transition: border-color .15s, box-shadow .15s;
}
.cdept-card:hover {
border-color: #dbe2fb;
box-shadow: 0 6px 18px rgba(30, 38, 53, .07);
}
.cdept-card.is-off {
background: #fbfcfd;
border-style: dashed;
}
.row-1 {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
flex: 0 0 6px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--green, #20a162);
}
.status-dot.is-off {
background: #cbd2df;
}
.card-name {
flex: 1;
min-width: 0;
margin: 0;
font-size: 15px;
font-weight: 600;
line-height: 1.4;
color: var(--text, #1e2635);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cdept-card.is-off .card-name {
color: var(--text-3, #98a1b2);
}
.row-2 {
display: flex;
align-items: center;
gap: 8px;
min-height: 26px;
margin-top: auto;
padding-top: 9px;
border-top: 1px dashed var(--line-2, #f0f3f9);
font-size: 12px;
color: var(--text-3, #98a1b2);
}
.row-2 .sub {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ops {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 8px;
}
.link {
color: var(--brand, #577afb);
cursor: pointer;
}
.link.danger {
color: #e54545;
}
.op {
font-style: normal;
font-size: 12px;
line-height: 18px;
width: 18px;
text-align: center;
border-radius: 4px;
color: var(--text-2, #5a6478);
cursor: pointer;
}
.op:hover {
background: #e8ecfa;
color: var(--brand, #577afb);
}
.op.is-disabled {
color: #d3d9e6;
cursor: not-allowed;
}
/* 启停开关(与 SYSTEM 卡片同一套视觉) */
.sw {
position: relative;
flex: 0 0 auto;
width: 36px;
height: 20px;
border-radius: 10px;
background: var(--brand, #577afb);
cursor: pointer;
transition: background .15s;
}
.sw::after {
content: '';
position: absolute;
top: 2px;
left: 18px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(30, 38, 53, .22);
transition: left .15s;
}
.sw.is-off {
background: #d3d9e6;
}
.sw.is-off::after {
left: 2px;
}
</style>
File diff suppressed because it is too large Load Diff
+485
View File
@@ -0,0 +1,485 @@
<template>
<Modal v-model="innerVisible" :title="title" width="760" :mask-closable="false"
@on-visible-change="onVisibleChange">
<div class="member-body">
<!-- 左:全部人员(本校教职工,服务端已排除学生组与在册成员) -->
<section class="pane">
<div class="pane-head">
<Checkbox :model-value="leftAllChecked" :indeterminate="leftIndeterminate"
@on-change="(v: any) => toggleAll(leftRows, leftChecked, !!v)" />
<span class="pane-title">全部人员</span>
<span class="pane-sub">{{ leftCheckedCount }}/{{ leftRows.length }}</span>
</div>
<div class="pane-filter">
<Input v-model="kwLeft" size="small" placeholder="请输入搜索内容" clearable search
@on-search="loadCandidates" @on-change="onKwLeftChange" />
</div>
<div v-if="loadingCandidates" class="pane-tip">加载中…</div>
<div v-else-if="!leftRows.length" class="pane-tip">
{{ kwLeft ? '没有匹配的人员' : '没有可添加的教职工' }}
</div>
<ul v-else class="pane-list">
<li v-for="u in leftRows" :key="u.userId" class="row" @dblclick="moveRightOne(u)">
<Checkbox :model-value="isChecked(leftChecked, u)" @on-change="(v: any) => toggleOne(leftChecked, u, !!v)">
<span class="row-name">{{ u.name || u.account }}</span>
<span class="row-sub">{{ u.account }}</span>
</Checkbox>
</li>
</ul>
<div v-if="candidates.length >= CANDIDATE_PAGE_HINT" class="pane-foot">
仅显示前 {{ CANDIDATE_PAGE_HINT }} 条,可输入关键字缩小范围。
</div>
</section>
<!-- 中:加入 / 移出 -->
<div class="mid">
<Button size="small" class="mid-btn" :disabled="!rightCheckedCount" title="移出成员"
@click="moveLeft()">&lt;</Button>
<Button size="small" type="primary" class="mid-btn" :disabled="!leftCheckedCount"
title="加入成员" @click="moveRight()">&gt;</Button>
</div>
<!-- 右:已有成员 -->
<section class="pane">
<div class="pane-head">
<Checkbox :model-value="rightAllChecked" :indeterminate="rightIndeterminate"
@on-change="(v: any) => toggleAll(rightRows, rightChecked, !!v)" />
<span class="pane-title">已有成员</span>
<span class="pane-sub">{{ rightRows.length }} 人</span>
</div>
<div class="pane-filter">
<Input v-model="kwRight" size="small" placeholder="请输入搜索内容" clearable />
</div>
<div v-if="loadingMembers" class="pane-tip">加载中…</div>
<div v-else-if="!rightRows.length" class="pane-tip">
{{ kwRight ? '没有匹配的成员' : '该部门还没有成员' }}
</div>
<ul v-else class="pane-list">
<li v-for="u in rightRows" :key="u.userId" class="row" @dblclick="moveLeftOne(u)">
<Checkbox :model-value="isChecked(rightChecked, u)" @on-change="(v: any) => toggleOne(rightChecked, u, !!v)">
<span class="row-name">{{ u.name || u.account }}</span>
<span class="row-sub">{{ u.account }}</span>
</Checkbox>
</li>
</ul>
</section>
</div>
<template #footer>
<div class="modal-footer">
<span v-if="pendingText" class="pending-tip">{{ pendingText }}</span>
<Button @click="innerVisible = false">取消</Button>
<Button type="primary" :loading="saving" :disabled="!hasPending" @click="save">保存</Button>
</div>
</template>
</Modal>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import Apis from "@/api";
import type { CustomDepartmentMemberVo, CustomDepartmentVo } from "@/api/globals";
/**
* 自定义部门成员管理弹窗(穿梭框式)。
* 左「全部人员」=本校教职工候选(服务端按「排除学生组 + 未在本部门」给),右「已有成员」=部门在册成员。
* 中间箭头只改本地待提交状态,点「保存」才一次性提交:先 Add 再 Remove,取消则整体丢弃。
* 候选走 {@code /api/customdept/CandidateUsers/{id}},不能用组维度的 GroupUsers(会带进学生)。
*/
const props = defineProps<{ visible: boolean; dept?: CustomDepartmentVo | null }>();
const emit = defineEmits<{ "update:visible": [boolean]; success: [] }>();
/** 后端候选上限 200,达到就提示用关键字筛选 */
const CANDIDATE_PAGE_HINT = 200;
const innerVisible = computed({
get: () => props.visible,
set: (v) => emit("update:visible", v),
});
const title = computed(() => `部门成员 · ${props.dept?.name || ""}`);
const deptId = computed(() => props.dept?.id ?? null);
/** 服务端数据 */
const members = ref<CustomDepartmentMemberVo[]>([]);
const candidates = ref<CustomDepartmentMemberVo[]>([]);
const loadingMembers = ref(false);
const loadingCandidates = ref(false);
const saving = ref(false);
/** 待提交:新增(左→右)与移出(右→左)。点保存才落库,取消整体丢弃 */
const pendingAdd = ref<CustomDepartmentMemberVo[]>([]);
const pendingRemove = ref<Set<number>>(new Set());
const kwLeft = ref("");
const kwRight = ref("");
const leftChecked = ref<Set<number>>(new Set());
const rightChecked = ref<Set<number>>(new Set());
function matchKw(u: CustomDepartmentMemberVo, k: string) {
return (
String(u.name || "").toLowerCase().includes(k) ||
String(u.account || "").toLowerCase().includes(k)
);
}
/**
* 左列表:候选池去掉待加入的,再补上「待移出的成员」——
* 服务端候选池天然不含在册成员,若不补回来,移出后想反悔就找不到了。
*/
const leftRows = computed<CustomDepartmentMemberVo[]>(() => {
const k = kwLeft.value.trim().toLowerCase();
const hit = (u: CustomDepartmentMemberVo) => !k || matchKw(u, k);
const back = members.value.filter((m) => pendingRemove.value.has(m.userId as number) && hit(m));
const pool = candidates.value.filter(
(c) => !pendingRemove.value.has(c.userId as number) && !isPendingAdd(c.userId as number) && hit(c)
);
return [...back, ...pool];
});
/** 右列表:在册成员去掉待移出的,加上待加入的 */
const rightRows = computed<CustomDepartmentMemberVo[]>(() => {
const k = kwRight.value.trim().toLowerCase();
const hit = (u: CustomDepartmentMemberVo) => !k || matchKw(u, k);
const held = members.value.filter((m) => !pendingRemove.value.has(m.userId as number));
return [...held, ...pendingAdd.value].filter(hit);
});
function isPendingAdd(userId: number) {
return pendingAdd.value.some((u) => u.userId === userId);
}
const hasPending = computed(() => pendingAdd.value.length > 0 || pendingRemove.value.size > 0);
const pendingText = computed(() => {
const parts: string[] = [];
if (pendingAdd.value.length) parts.push(`新增 ${pendingAdd.value.length} 人`);
if (pendingRemove.value.size) parts.push(`移出 ${pendingRemove.value.size} 人`);
return parts.join(" · ");
});
/* ================= 勾选 ================= */
function isChecked(box: Set<number>, u: CustomDepartmentMemberVo) {
return box.has(u.userId as number);
}
function toggleOne(box: Set<number>, u: CustomDepartmentMemberVo, checked: boolean) {
const userId = u.userId as number;
if (checked) box.add(userId);
else box.delete(userId);
// Set 自身不是响应式依赖,换一个实例触发重算
if (box === leftChecked.value) leftChecked.value = new Set(box);
else rightChecked.value = new Set(box);
}
function moveRightOne(u: CustomDepartmentMemberVo) {
if (u.userId) moveRight([u.userId]);
}
function moveLeftOne(u: CustomDepartmentMemberVo) {
if (u.userId) moveLeft([u.userId]);
}
function toggleAll(rows: CustomDepartmentMemberVo[], box: Set<number>, checked: boolean) {
const ids = rows.map((u) => u.userId as number);
const next = new Set(box);
ids.forEach((id) => (checked ? next.add(id) : next.delete(id)));
if (box === leftChecked.value) leftChecked.value = next;
else rightChecked.value = next;
}
/* 全选 / 半选一律按「当前可见行」判断:关键字筛选后仍被勾着的行不算数 */
const leftCheckedCount = computed(
() => leftRows.value.filter((u) => leftChecked.value.has(u.userId as number)).length
);
const rightCheckedCount = computed(
() => rightRows.value.filter((u) => rightChecked.value.has(u.userId as number)).length
);
const leftAllChecked = computed(() => leftRows.value.length > 0 && leftCheckedCount.value >= leftRows.value.length);
const leftIndeterminate = computed(() => leftCheckedCount.value > 0 && !leftAllChecked.value);
const rightAllChecked = computed(() => rightRows.value.length > 0 && rightCheckedCount.value >= rightRows.value.length);
const rightIndeterminate = computed(() => rightCheckedCount.value > 0 && !rightAllChecked.value);
/* ================= 左右移动 ================= */
/** 加入:左侧勾选(或双击某行)→ 挂到待新增 */
function moveRight(userIds?: number[]) {
const ids = userIds ?? leftRows.value.filter((u) => leftChecked.value.has(u.userId as number)).map((u) => u.userId as number);
if (!ids.length) return;
const picked = leftRows.value.filter((u) => ids.includes(u.userId as number));
pendingAdd.value = [...pendingAdd.value, ...picked];
// 移出后再加入 = 撤销移出,不用重复 Add
const nextRemove = new Set(pendingRemove.value);
picked.forEach((u) => nextRemove.delete(u.userId as number));
pendingRemove.value = nextRemove;
leftChecked.value = new Set();
}
/** 移出:右侧勾选(或双击某行)→ 撤掉待新增,或记账到待移出 */
function moveLeft(userIds?: number[]) {
const ids = userIds ?? rightRows.value.filter((u) => rightChecked.value.has(u.userId as number)).map((u) => u.userId as number);
if (!ids.length) return;
const stillPending = new Set<number>();
pendingAdd.value = pendingAdd.value.filter((u) => {
if (ids.includes(u.userId as number)) {
stillPending.add(u.userId as number);
return false;
}
return true;
});
const nextRemove = new Set(pendingRemove.value);
ids.forEach((id) => {
if (!stillPending.has(id)) nextRemove.add(id);
});
pendingRemove.value = nextRemove;
rightChecked.value = new Set();
}
/* ================= 数据加载 ================= */
async function loadMembers() {
if (!deptId.value) return;
loadingMembers.value = true;
try {
members.value = ((await Apis.CustomDept.customDeptMembers({
pathParams: { id: deptId.value },
cacheFor: null,
})) as CustomDepartmentMemberVo[]) || [];
} catch (e: any) {
console.warn(e?.message || "加载部门成员失败");
members.value = [];
} finally {
loadingMembers.value = false;
}
}
async function loadCandidates() {
if (!deptId.value) return;
loadingCandidates.value = true;
try {
candidates.value = ((await Apis.CustomDept.customDeptCandidateUsers({
pathParams: { id: deptId.value },
params: { keyword: kwLeft.value.trim() || undefined },
cacheFor: null,
})) as CustomDepartmentMemberVo[]) || [];
} catch (e: any) {
console.warn(e?.message || "加载候选教职工失败");
candidates.value = [];
} finally {
loadingCandidates.value = false;
}
}
function onKwLeftChange() {
// clearable 清空时也当一次搜索
if (!kwLeft.value) loadCandidates();
}
/* ================= 保存 ================= */
async function save() {
if (!deptId.value || !hasPending.value) return;
saving.value = true;
try {
if (pendingAdd.value.length) {
await Apis.CustomDept.customDeptAddMembers({
pathParams: { id: deptId.value },
data: {
schoolId: props.dept?.schoolId as number,
userIds: pendingAdd.value.map((u) => u.userId as number),
},
cacheFor: null,
});
}
if (pendingRemove.value.size) {
await Apis.CustomDept.customDeptRemoveMembers({
pathParams: { id: deptId.value },
data: { schoolId: props.dept?.schoolId as number, userIds: [...pendingRemove.value] },
cacheFor: null,
});
}
Message.success("已保存");
emit("success");
innerVisible.value = false;
} catch (e: any) {
// 失败提示由全局 alova 拦截器弹出,弹窗保持打开让用户重试
console.warn(e?.message || "保存成员失败");
} finally {
saving.value = false;
}
}
/* ================= 生命周期 ================= */
function reset() {
members.value = [];
candidates.value = [];
pendingAdd.value = [];
pendingRemove.value = new Set();
leftChecked.value = new Set();
rightChecked.value = new Set();
kwLeft.value = "";
kwRight.value = "";
}
function onVisibleChange(v: boolean) {
if (!v) return;
reset();
loadMembers();
loadCandidates();
}
// 外部直接改 visible 时同步一次
watch(() => props.visible, (v) => {
if (v) onVisibleChange(true);
});
</script>
<style lang="scss" scoped>
.member-body {
display: flex;
align-items: stretch;
gap: 8px;
height: 430px;
}
.pane {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
border: 1px solid var(--line, #e2e6ef);
border-radius: 4px;
overflow: hidden;
background: #fff;
}
.pane-head {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
height: 38px;
padding: 0 10px;
border-bottom: 1px solid #f1f4f9;
}
.pane-title {
font-size: 13px;
font-weight: 600;
color: var(--text, #1e2635);
}
.pane-sub {
margin-left: auto;
font-size: 12px;
color: var(--text-3, #98a1b2);
white-space: nowrap;
}
.pane-filter {
flex: 0 0 auto;
padding: 8px 10px 6px;
}
.pane-list {
flex: 1;
min-height: 0;
margin: 0;
padding: 0 6px 6px;
list-style: none;
overflow: auto;
}
.pane-tip {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 0 16px;
font-size: 12.5px;
line-height: 1.8;
color: var(--text-3, #98a1b2);
}
.pane-foot {
flex: 0 0 auto;
padding: 6px 10px 8px;
font-size: 11.5px;
color: var(--text-3, #98a1b2);
border-top: 1px solid #f6f8fb;
}
.row {
display: flex;
align-items: center;
height: 30px;
padding: 0 4px;
border-radius: 3px;
font-size: 13px;
cursor: pointer;
user-select: none;
&:hover {
background: #f5f7fb;
}
:deep(.ivu-checkbox-wrapper) {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
margin-right: 0;
}
:deep(.ivu-checkbox) {
flex: 0 0 auto;
margin-right: 6px;
}
}
.row-name {
font-weight: 500;
color: #272829;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-sub {
flex: 0 0 auto;
margin-left: 6px;
font-size: 12px;
color: #9aa1ad;
}
/* 中间箭头列 */
.mid {
flex: 0 0 36px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
}
.mid-btn {
width: 32px;
padding: 0;
font-weight: 600;
}
.modal-footer {
display: flex;
align-items: center;
gap: 8px;
}
.pending-tip {
margin-right: auto;
font-size: 12px;
color: var(--text-3, #98a1b2);
}
</style>
+197
View File
@@ -0,0 +1,197 @@
<!-- 系统部门卡片(已被 index.vue 的表格取代,2026-10-23)。保留文件仅作参考,无任何引用,可安全删除。 -->
<template>
<article class="dept-card" :class="{ 'is-off': disabled, 'has-kids': showKids }">
<div class="row-1">
<span class="status-dot" :class="{ 'is-off': disabled }"></span>
<h3 class="card-name" :title="dept.name">{{ dept.name }}</h3>
<span class="sw" :class="{ 'is-off': disabled }" title="启用 / 停用" @click.stop="emit('toggle', dept)"></span>
</div>
<div class="row-2">
<span class="sub">{{ subText }}</span>
</div>
<div v-if="showKids" class="kid-list">
<DeptCard v-for="kid in kids" :key="kid.id" :dept="kid" :level="level + 1"
:include-children="includeChildren" @toggle="onKidToggle" />
</div>
</article>
</template>
<script lang="ts" setup>
import { computed } from "vue";
/** 部门口径:教师组下挂的都是部门,只有学生组(type=8)不算部门 */
const STUDENT_GROUP_TYPE = 8;
const props = withDefaults(defineProps<{
dept: any;
/** 层级深度:0 = 一级部门,>0 = 下级部门 */
level?: number;
/** 是否包含下级部门(对应学校上的「包含子组」开关) */
includeChildren?: boolean;
}>(), {
level: 0,
includeChildren: true,
});
const emit = defineEmits<{ (e: "toggle", dept: any): void }>();
const onKidToggle = (dept: any) => emit("toggle", dept);
const kids = computed<any[]>(() => props.dept?.children || []);
const showKids = computed(() => props.includeChildren !== false && kids.value.length > 0);
const disabled = computed(() => props.dept?.status === 1);
const subText = computed(() => {
if (props.dept?.type === STUDENT_GROUP_TYPE) return "学生组 · 非部门";
if (props.level > 0) return "下级部门";
return kids.value.length ? `${kids.value.length} 个下级部门` : "无下级部门";
});
</script>
<style scoped lang="scss">
.dept-card {
display: flex;
flex-direction: column;
gap: 9px;
padding: 13px 14px;
background: var(--panel, #fff);
border: 1px solid var(--line, #e9edf5);
border-radius: var(--r, 10px);
box-shadow: 0 1px 2px rgba(30, 38, 53, .03);
transition: border-color .15s, box-shadow .15s;
}
.dept-card:hover {
border-color: #dbe2fb;
box-shadow: 0 6px 18px rgba(30, 38, 53, .07);
}
/* 有下级部门:跨整行成组,避免层级被压平 */
.dept-card.has-kids {
grid-column: 1 / -1;
}
.dept-card.is-off {
background: #fbfcfd;
border-style: dashed;
}
.row-1 {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
flex: 0 0 6px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--green, #20a162);
}
.status-dot.is-off {
background: #cbd2df;
}
.card-name {
flex: 1;
min-width: 0;
margin: 0;
font-size: 15px;
font-weight: 600;
line-height: 1.4;
color: var(--text, #1e2635);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dept-card.is-off .card-name {
color: var(--text-3, #98a1b2);
}
.row-2 {
display: flex;
align-items: center;
gap: 8px;
min-height: 26px;
margin-top: auto;
padding-top: 9px;
border-top: 1px dashed var(--line-2, #f0f3f9);
font-size: 12px;
color: var(--text-3, #98a1b2);
}
.row-2 .sub {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 启停开关 */
.sw {
position: relative;
flex: 0 0 auto;
width: 36px;
height: 20px;
border-radius: 10px;
background: var(--brand, #577afb);
cursor: pointer;
transition: background .15s;
}
.sw::after {
content: '';
position: absolute;
top: 2px;
left: 18px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(30, 38, 53, .22);
transition: left .15s;
}
.sw.is-off {
background: #d3d9e6;
}
.sw.is-off::after {
left: 2px;
}
/* 下级部门区 */
.kid-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 11px;
padding-top: 11px;
border-top: 1px dashed var(--line-2, #f0f3f9);
}
.kid-list :deep(.dept-card) {
flex: 1 1 168px;
min-width: 150px;
background: #fafbfe;
box-shadow: none;
}
.kid-list :deep(.dept-card:hover) {
background: #fff;
}
.kid-list :deep(.dept-card .card-name) {
font-size: 13.5px;
font-weight: 500;
}
/* 下级部门里若还有下级,占满一行表达更深层级 */
.kid-list :deep(.dept-card.has-kids) {
flex: 1 1 100%;
}
</style>
+771
View File
@@ -0,0 +1,771 @@
<template>
<div class="page-wrapper tree-wrapper dept-manage-page">
<div v-if="!roleReady" class="dept-loading">加载中…</div>
<template v-else>
<!-- 左侧:zTree 组树(超管 / 局端;只到学校层) -->
<div v-if="showTree" class="tree-container dept-manage-tree">
<GroupTreeSearchPanel class="tree-inner" ref="refTreePanel" v-model:curTreeNode="selectedNode"
:treeApi="treeApi" :defaultSearchVisible="true" @on-change="onTreeChange" />
</div>
<!-- 右侧:部门表格(白卡由主题的 .main-container 提供) -->
<div class="main-container dept-manage-main">
<div class="panel-title">
<span class="panel-caption">部门管理</span>
<Tag v-if="currentSchoolName" color="primary" class="ml-8">{{ currentSchoolName }}</Tag>
<Select v-if="multiSchool" v-model="currentSchoolId" size="small" class="ml-8 school-select">
<Option v-for="s in schoolOptions" :key="s.id" :value="s.id">{{ s.name }}</Option>
</Select>
<!-- 来源切换:只切右侧管理视图,学校上下文共用,不改底层组织树 -->
<div class="title-tail">
<span v-if="source === 'SYSTEM'" class="inc-ctrl" title="打开后列出该校下所有层级的部门"
@click="toggleInclude">
<span class="inc-label">包含子组</span>
<i class="sw-mini" :class="{ 'is-on': includeOn }"></i>
</span>
<div class="source-tabs">
<span class="source-tab" :class="{ 'is-active': source === 'SYSTEM' }"
@click="source = 'SYSTEM'">系统部门</span>
<span class="source-tab" :class="{ 'is-active': source === 'CUSTOM' }"
@click="source = 'CUSTOM'">自定义部门</span>
</div>
</div>
</div>
<template v-if="source === 'SYSTEM'">
<Alert show-icon type="success" class="usage-note">
<b>设置说明:</b>
停用只影响「选」(新建事项 / 流程绑定时选不到),不影响「看」(已有事项展示)与「办」(在途流程照常解析);
关闭「包含子组」只展示一级部门,打开后连同下级部门一并列出。
</Alert>
<Alert v-if="!currentSchoolName" show-icon type="info" class="state-tip">
{{ showTree ? '请在左侧选择学校节点' : '当前账号没有可管理的学校' }}
</Alert>
<Alert v-else-if="!deptRows.length" show-icon type="info" class="state-tip">
该校教师组下暂无部门。部门 = 教师组的直接子节点,可前往「组,用户管理」建组。
</Alert>
<template v-else>
<div class="row-sum">{{ deptRows.length }} 个一级部门 · 共 {{ flatRows.length }} 行</div>
<Table class="dept-itable" border :columns="sysColumns" :data="flatRows"
:row-class-name="rowClassName" :loading="false" no-data-text="暂无部门">
<template #name="{ row }">
<span class="nmcell" :style="{ paddingLeft: row.level * 20 + 'px' }">
<i v-if="row._kids.length" class="tw" @click.stop="toggleRow(row.id)">{{ row.open ? '▾' : '▸' }}</i>
<i v-else class="tw blank"></i>
<span class="nm" :class="{ root: row.level === 0 }" :title="row.name">{{ row.name }}</span>
<span v-if="row.kidCount" class="cnt">{{ row.kidCount }} 个下级</span>
<span v-if="row.isStudent" class="cnt grey">学生组</span>
</span>
</template>
<template #members="{ row }">
<Tooltip v-if="row.memberCount" placement="top" max-width="360" :content="memberTip(row)">
<span class="mem" :class="memberClickable(row) ? 'mem-cell' : 'mem-plain'"
@click="onMemberClick(row)">
<span class="names">{{ memberHead(row) }}</span>
<span v-if="memberRest(row)" class="more">+{{ memberRest(row) }}</span>
</span>
</Tooltip>
<span v-else class="dim">无成员</span>
</template>
<template #status="{ row }">
<i class="sw-mini" :class="{ 'is-on': row.status !== 1 }"
:title="row.status === 1 ? '点击启用' : '点击停用'"
@click="onToggle(row.raw)"></i>
</template>
</Table>
<div class="table-foot">共 {{ flatRows.length }} 个部门</div>
</template>
</template>
<CustomDeptPanel v-else :school-id="currentSchoolId" />
<!-- 系统部门人员:预览之外看全部,走 /api/sysuser/List?groupId= -->
<Modal v-model="memModal.visible" :title="`部门人员 · ${memModal.deptName}`" width="560"
:mask-closable="true" footer-hide>
<div class="mem-modal-body">
<div v-if="memModal.loading" class="mem-empty">加载中…</div>
<div v-else-if="!memModal.rows.length" class="mem-empty">该部门暂无直属成员</div>
<ul v-else class="mem-list">
<li v-for="u in memModal.rows" :key="u.id">
<span class="mname">{{ u.realName || u.account }}</span>
<span class="macc">{{ u.account }}</span>
</li>
</ul>
</div>
<div v-if="memModal.total > memModal.pageSize" class="mem-pager">
<Page :current="memModal.page" :total="memModal.total" :page-size="memModal.pageSize"
size="small" show-total @on-change="onMemPageChange" />
</div>
</Modal>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from "vue";
import { cloneDeep } from "lodash";
import Apis from "@/api";
import GroupTreeSearchPanel from "@/components/GroupTreeSearchPanel.vue";
import MyConfirm from "@/components/MyConfirm";
import CustomDeptPanel from "./CustomDeptPanel.vue";
import { Message } from "view-ui-plus";
import { useDefaultStore } from "@/store";
import { UserRoleEnum } from "@/types";
/**
* 部门管理(2026-10-23 表格版)。
* 口径:部门 = 教师组(type=7)的直接子节点,下可再挂子部门(只有教师组下才可能有多级)。
* 视角:超管 / 局端 → 左侧 zTree 树只到「学校」层,右侧是该学校的部门表格;
* 学校管理员 → 无树,直接列出本校部门(多校时顶部下拉切换)。
* 表格:层级用缩进 + 折叠表达;不提供新增 / 排序 / 行内操作,只保留启停与「包含子组」。
* 人员:getTree 已带 memberCount + memberNames(前 50),超出走 /api/sysuser/List 看全部。
*/
const TEACHER_GROUP_TYPE = 7;
const STUDENT_GROUP_TYPE = 8;
const SCHOOL_GROUP_TYPE = 2;
const store = useDefaultStore();
const isSchoolMode = computed(() => store.userRole === UserRoleEnum.School);
/** 超管 / 局端才需要左侧树 */
const showTree = computed(() => !isSchoolMode.value);
/** 角色未落定前不渲染,避免先按默认角色取数再纠正 */
const roleReady = computed(() => store.roleLoaded);
const refTreePanel = ref<InstanceType<typeof GroupTreeSearchPanel>>();
const selectedNode = ref<any>({});
/** 部门来源页签:SYSTEM=原组织树视图(行为零改动);CUSTOM=学校自建分类/部门 */
const source = ref<"SYSTEM" | "CUSTOM">("SYSTEM");
/** 全量组树:右侧部门数据一律从这里按 id 回查真实 children */
const fullTree = ref<any[]>([]);
/** 当前学校:超管由树选中,学校管理员默认取可管理学校 */
const currentSchoolId = ref<number | undefined>(undefined);
const schoolIds = computed<number[]>(() => store.userScope?.schoolGroupIds ?? []);
const multiSchool = computed(() => isSchoolMode.value && schoolIds.value.length > 1);
const schoolOptions = computed(() => schoolIds.value.map((id) => ({
id,
name: findNodeById(fullTree.value, id)?.name ?? String(id),
})));
const currentSchool = computed(() => findNodeById(fullTree.value, currentSchoolId.value));
const currentSchoolName = computed(() => currentSchool.value?.name || "");
/** 折叠状态:默认全展开,只记「被收起的 id」 */
const closedIds = ref<Set<number>>(new Set());
function toggleRow(id: number) {
const next = new Set(closedIds.value);
if (next.has(id)) next.delete(id);
else next.add(id);
closedIds.value = next;
}
interface DeptRow {
id: number;
name: string;
status: number;
parentName: string;
level: number;
isStudent: boolean;
memberCount: number;
memberNames: string[];
kidCount: number;
open: boolean;
no: number;
raw: any;
/**
* 子级只给自己用。
* ⚠️ 绝不能叫 children:iView Table 发现行上有 children 就切「树形数据模式」(还要求 row-key),
* 会和这里自画的缩进 + 折叠打架,表现是整表空白(关掉包含子组后 children 为空数组才恢复)。
*/
_kids: DeptRow[];
}
/** 表格树:一级部门来自各教师组下的直接子节点,子级递归;「包含子组」关闭时不带子级 */
const deptRows = computed<DeptRow[]>(() => {
const school = currentSchool.value;
if (!school) return [];
const rows: DeptRow[] = [];
(school.children || [])
.filter((c: any) => c.type === TEACHER_GROUP_TYPE)
.forEach((t: any) => {
(t.children || []).forEach((d: any) => rows.push(toRow(d, t.name, 0)));
});
return rows;
});
function toRow(d: any, parentName: string, level: number): DeptRow {
const kids: any[] = d.children || [];
return {
id: d.id,
name: d.name,
status: d.status ?? 0,
parentName,
level,
isStudent: d.type === STUDENT_GROUP_TYPE,
memberCount: Number(d.memberCount ?? 0),
memberNames: Array.isArray(d.memberNames) ? d.memberNames : [],
// 下级数始终按真实层级给:关闭包含子组时只收起子行,徽标仍要能看出「这个部门有下级」
kidCount: kids.length,
open: !closedIds.value.has(d.id),
no: 0,
raw: d,
_kids: includeOn.value ? kids.map((k) => toRow(k, d.name, level + 1)) : [],
};
}
/** 拍平成可渲染行(按折叠状态裁剪子级),并顺序编号 */
const flatRows = computed<DeptRow[]>(() => {
const out: DeptRow[] = [];
const walk = (list: DeptRow[]) => {
list.forEach((row) => {
out.push(row);
if (row.open && row._kids.length) walk(row._kids);
});
};
walk(deptRows.value);
out.forEach((row, i) => (row.no = i + 1));
return out;
});
/** iView Table 列定义:层级用「部门名称」列的缩进 + 折叠表达,不用 Table 自带树(避免样式不可控) */
const sysColumns: any[] = [
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "部门名称", slot: "name", width: 236 },
{ title: "上级", key: "parentName", width: 130 },
{ title: "人员", slot: "members", minWidth: 200 },
{ title: "状态", slot: "status", width: 90, align: "center" },
];
/** 停用行整行灰化 */
function rowClassName(row: any) {
return row?.status === 1 ? "is-off" : "";
}
/** 人员列:最多平铺 10 个姓名,超出用「+N」提示;已全部显示(≤10 人)时不可点 */
const MEMBER_PREVIEW = 10;
function memberHead(row: DeptRow) {
// 姓名预览还没回来(后端未升级 / 未重启)时先退回人数,避免出现空单元格
if (!row.memberNames.length) return `${row.memberCount} 人`;
return row.memberNames.slice(0, MEMBER_PREVIEW).join("、");
}
function memberRest(row: DeptRow) {
if (!row.memberNames.length) return 0;
return Math.max(0, row.memberCount - Math.min(MEMBER_PREVIEW, row.memberNames.length));
}
/** 只有列不完时才值得点开弹窗(全在表格里看完了,弹窗没有增量信息) */
function memberClickable(row: DeptRow) {
return row.memberCount > MEMBER_PREVIEW;
}
function onMemberClick(row: DeptRow) {
if (memberClickable(row)) openMembers(row);
}
function memberTip(row: DeptRow) {
if (!row.memberNames.length) return `共 ${row.memberCount} 人 点击查看全部`;
const list = row.memberNames.join("、");
const more = row.memberCount > row.memberNames.length ? `…(仅预览前 ${row.memberNames.length} 人)` : "";
return `共 ${row.memberCount} 人:${list}${more}${memberClickable(row) ? " 点击查看全部" : ""}`;
}
/* ================= 人员弹窗(查看全部,分页) ================= */
const memModal = reactive({
visible: false,
loading: false,
deptId: 0,
deptName: "",
rows: [] as any[],
total: 0,
page: 1,
pageSize: 20,
});
function openMembers(row: DeptRow) {
Object.assign(memModal, {
visible: true, loading: true, deptId: row.id, deptName: row.name,
rows: [], total: 0, page: 1,
});
loadMembers();
}
function onMemPageChange(page: number) {
memModal.page = page;
loadMembers();
}
async function loadMembers() {
memModal.loading = true;
try {
const res: any = await Apis.SysUser.list({
params: { groupId: memModal.deptId, pageIndex: memModal.page, pageSize: memModal.pageSize },
cacheFor: null,
});
memModal.rows = res?.content ?? [];
memModal.total = Number(res?.total ?? 0);
} catch (e: any) {
console.warn(e?.message || "加载部门人员失败");
memModal.rows = [];
memModal.total = 0;
} finally {
memModal.loading = false;
}
}
/**
* 当前学校的「包含子组」:读学校节点上的 includeChildren(1=含子组 / 0=仅本级)。
* 历史数据或旧库该列为 null 时按「含子组」兜底,与升级前表现一致。
*/
const includeOn = computed(() => {
const school = currentSchool.value;
return school ? school.includeChildren !== 0 : false;
});
/* ================= 数据加载 ================= */
/** 树数据源:取全量树存起来给右侧用,树上只渲染到学校层 */
async function treeApi() {
const full: any[] = (await Apis.SysGroup.getTree()) || [];
fullTree.value = cloneDeep(full);
return pruneToSchoolLevel(full);
}
/** 剪枝:学校节点保留但不再下钻(超管视角树只到学校层) */
function pruneToSchoolLevel(list: any[]): any[] {
return (list || []).map((n: any) => {
const node: any = { ...n };
if (node.type === SCHOOL_GROUP_TYPE) {
delete node.children;
return node;
}
if (Array.isArray(node.children) && node.children.length) {
node.children = pruneToSchoolLevel(node.children);
}
return node;
});
}
function findNodeById(list: any[], id?: number | null): any {
if (id == null) return null;
for (const n of list || []) {
if (Number(n.id) === Number(id)) return n;
const hit = findNodeById(n.children || [], id);
if (hit) return hit;
}
return null;
}
/* ================= 交互 ================= */
function onTreeChange(node: any) {
selectedNode.value = node || {};
if (node?.type === SCHOOL_GROUP_TYPE) {
currentSchoolId.value = node.id;
closedIds.value = new Set();
} else if (node?.id) {
// 点到中间层(区域 / 学校组等)不作为当前学校
currentSchoolId.value = undefined;
}
}
/** 切换「包含子组」:落库到学校节点,成功后就地更新,避免整树重绘丢失选中态 */
async function toggleInclude() {
const school = currentSchool.value;
if (!school?.id) return;
const next = !includeOn.value;
try {
await Apis.SysGroup.setIncludeChildren({
pathParams: { id: school.id },
data: { includeChildren: next },
});
school.includeChildren = next ? 1 : 0;
closedIds.value = new Set();
Message.success(next ? "已包含下级部门" : "仅显示一级部门");
} catch (e: any) {
// 全局 alova 拦截器(alova-iview 的 onBizError/onHttpError)已统一 Message.error,此处只记日志避免二次弹窗;
// 本地字段只在写成功后改,失败即自然停在原值,无需回滚
console.warn(e?.message || "设置包含子组失败");
}
}
function onToggle(dept: any) {
if (!dept?.id) return;
if (dept.status === 1) {
doEnable(dept);
return;
}
MyConfirm({
title: "停用部门",
msg: `确定停用部门【${dept.name}】?`,
info: "停用只影响「选」(新建时选不到),不影响「看」和「办」(已有事项展示、在途流程照常解析)。",
async onOk() {
await doDisable(dept);
},
});
}
async function doDisable(dept: any) {
try {
await Apis.SysGroup.disable({ pathParams: { id: dept.id } });
Message.success("已停用");
await syncTreeData();
} catch (e: any) {
console.warn(e?.message || "停用失败");
}
}
async function doEnable(dept: any) {
try {
await Apis.SysGroup.enable({ pathParams: { id: dept.id } });
Message.success("已启用");
await syncTreeData();
} catch (e: any) {
console.warn(e?.message || "启用失败");
}
}
/**
* 静默同步组树数据:只替换右侧数据源,**不重建左侧 zTree**。<br>
* 启停改的是部门自身状态,而树只渲染到学校层,部门节点根本不在树上——
* 走 refreshTree 会让整棵树重绘、展开态与滚动位置一起丢失。
*/
async function syncTreeData() {
const full: any[] = (await Apis.SysGroup.getTree()) || [];
fullTree.value = cloneDeep(full);
}
/* ================= 初始化 ================= */
watch(() => store.roleLoaded, (loaded) => {
if (!loaded || !isSchoolMode.value) return;
loadSchoolScope();
}, { immediate: true });
/** 学校管理员:GetTree 返回的就是各学校子树(多校 = 多根) */
async function loadSchoolScope() {
const full: any[] = (await Apis.SysGroup.getTree()) || [];
fullTree.value = cloneDeep(full);
const ids = schoolIds.value;
if (ids.length) {
currentSchoolId.value = ids[0];
} else if (full.length) {
currentSchoolId.value = full[0]?.id;
}
}
</script>
<style scoped lang="scss">
.dept-manage-page {
/* 骨架交由主题 .tree-wrapper(flex + gap 16 + padding 16)+ .main-container(白卡);
这里只声明两卡等高,间距/留白一律不覆盖 */
align-items: stretch;
box-sizing: border-box;
/* 树:外层不滚(滚动交给内部 zTree),否则内容未超高也会因 1~2px 溢出出现滚动条 */
.dept-manage-tree {
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
.tree-inner {
flex: 1;
min-height: 0;
}
}
}
/* 右侧白卡内部纵向排布:标题行 / 说明条 / 表格 */
.dept-manage-main {
display: flex;
flex-direction: column;
min-height: 0;
overflow: auto;
}
.panel-title {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 12px;
}
.panel-caption {
font-size: 14px;
font-weight: 600;
color: var(--text, #1e2635);
}
/* 标题行右侧:包含子组 + 来源切换,整体右对齐;「包含子组」放左边,切视图时 tab 位置不跳 */
.title-tail {
margin-left: auto;
display: flex;
align-items: center;
gap: 14px;
}
.school-select {
width: 180px;
}
/* 来源切换:同页两个视图,不新增菜单项 */
.source-tabs {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
background: #f2f4f9;
border-radius: 8px;
}
.source-tab {
padding: 3px 10px;
font-size: 12.5px;
line-height: 18px;
border-radius: 6px;
color: var(--text-2, #5a6478);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.source-tab.is-active {
background: var(--panel, #fff);
color: var(--brand, #577afb);
font-weight: 600;
box-shadow: 0 1px 2px rgba(30, 38, 53, .08);
}
/* 面板内顶部说明条的间距(外观完全交给 iView Alert 自身,不覆写配色) */
.usage-note {
margin-bottom: 14px;
}
/* 空态 / 无权限等临时提示 */
.state-tip {
margin-bottom: 12px;
}
.dept-loading {
flex: 1;
text-align: center;
padding-top: 80px;
color: var(--text-3, #98a1b2);
}
/* 面板头的「包含子组」开关 */
.inc-ctrl {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
font-size: 12.5px;
color: var(--text-2, #5a6478);
}
/* 「包含子组」开关:面板头与 zTree 树上共用同一套样式。
树上的控件是运行时创建的 DOM,没有 scoped 标记,只能用 :deep 命中 */
:deep(.sw-mini) {
position: relative;
display: inline-block;
width: 28px;
height: 16px;
border-radius: 8px;
background: #d3d9e6;
transition: background .15s;
vertical-align: middle;
&::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 2px rgba(30, 38, 53, .2);
transition: left .15s;
}
&.is-on {
background: var(--brand, #577afb);
&::after {
left: 14px;
}
}
}
/* ================= 部门表格(iView Table) ================= */
.row-sum {
flex: 0 0 auto;
margin-bottom: 8px;
font-size: 11.5px;
color: var(--text-3, #98a1b2);
}
/* 表格本体外观全部交给 iView:只补单元格内部的排版细节 */
.dept-itable {
:deep(.ivu-table-cell) {
padding-left: 12px;
padding-right: 12px;
}
:deep(.ivu-table-row.is-off td) {
background: #fcfcfd;
color: var(--text-3, #98a1b2);
}
:deep(.ivu-table-row.is-off:hover td) {
background: #f8f9fb;
}
}
.nmcell {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.nmcell .tw {
flex: 0 0 auto;
width: 14px;
text-align: center;
font-style: normal;
font-size: 9px;
color: #aeb6c2;
cursor: pointer;
}
.nmcell .tw.blank {
visibility: hidden;
}
.nmcell .nm {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nmcell .nm.root {
font-weight: 600;
color: var(--text, #1e2635);
}
.nmcell .cnt {
flex: 0 0 auto;
padding: 0 5px;
line-height: 16px;
border-radius: 4px;
background: #f1f3f7;
color: #8a94a6;
font-size: 10.5px;
}
.nmcell .cnt.grey {
background: #f6f7fa;
color: #a3adbd;
}
/* 人员列:姓名区可截断,「+N」固定不收缩(名字太长时也得看得出还有没列完的人) */
.mem {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
max-width: 100%;
color: var(--text-2, #5a6478);
}
.mem .names {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mem .more {
flex: 0 0 auto;
font-size: 11.5px;
color: var(--text-3, #98a1b2);
}
/* 只有超过 10 人才可点(全部列完再弹窗没有增量信息) */
.mem-cell {
cursor: pointer;
}
.mem-cell:hover .names {
color: var(--brand, #577afb);
text-decoration-line: underline;
text-decoration-style: dashed;
text-underline-offset: 3px;
}
.mem-plain {
cursor: default;
}
.dim {
color: var(--text-3, #98a1b2);
font-size: 11.5px;
}
/* 状态开关:复用「包含子组」那套胶囊;表格里要跟文字垂直居中 */
.table-foot {
flex: 0 0 auto;
padding: 10px 2px;
text-align: right;
font-size: 12px;
color: var(--text-3, #98a1b2);
}
/* ================= 人员弹窗 ================= */
.mem-modal-body {
max-height: 380px;
overflow: auto;
}
.mem-empty {
padding: 26px 0;
text-align: center;
font-size: 12.5px;
color: var(--text-3, #98a1b2);
}
.mem-list {
margin: 0;
padding: 0;
list-style: none;
li {
display: flex;
align-items: center;
gap: 10px;
height: 34px;
padding: 0 4px;
border-bottom: 1px solid #f2f5f9;
font-size: 12.5px;
&:last-child {
border-bottom: 0;
}
}
.mname {
color: var(--text, #1e2635);
}
.macc {
margin-left: auto;
color: var(--text-3, #98a1b2);
font-size: 11.5px;
}
}
.mem-pager {
display: flex;
justify-content: flex-end;
padding-top: 8px;
}
</style>
+5 -5
View File
@@ -352,8 +352,8 @@ onMounted(async () => {
padding-bottom: 10px;
li {
padding: 10px;
height: 38px;
padding: 8px;
height: 34px;
border-radius: 50%;
cursor: pointer;
@@ -363,12 +363,12 @@ onMounted(async () => {
}
svg {
width: 18px;
height: 18px;
width: 16px;
height: 16px;
}
.ivu-icon {
font-size: 18px;
font-size: 16px;
}
}
+20 -43
View File
@@ -14,7 +14,7 @@
v-model="keyword" @on-search="onSearch" @on-clear="onClear" />
</div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight">
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns">
<template #default="{ checkList }">
<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>
@@ -59,7 +59,7 @@
<script lang="ts" setup>
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 MyConfirm from "@/components/MyConfirm";
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
@@ -70,10 +70,11 @@ import EditUserContainer from './user/EditUserContainer.vue';
import AdjustGroupContainer from './AdjustGroupContainer.vue';
import BatchImportUser from './user/BatchImportUser.vue';
import UserSort from './user/UserSort.vue';
import { actionColumn } from "@/utils/tableActions";
defineOptions({ name: "GroupUser" });
const { $utils, $conf } = useCurrentInstance();
const { $conf } = useCurrentInstance();
const treeApi = () => Apis.SysGroup.getTree();
@@ -146,8 +147,6 @@ const tableApi = (params: { pageIndex: number; pageSize: number }) => {
});
};
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }))
/* ================= 表格列 ================= */
const columns = [
{ type: 'selection', width: 55, align: 'center' },
@@ -157,55 +156,37 @@ const columns = [
{ title: '手机号', key: 'phone', minWidth: 140 },
{
title: '用户身份', key: 'identityNames', minWidth: 180,
// 数据源:后端按页批量带出的主校身份名(is_main 行,roles 列已停写,不再直读旧列)
// 数据源:后端按页批量带出的身份名,且只取「当前所选组范围解析出的学校」下的身份行
// (与编辑弹窗「所在组」回显同口径,roles 列已停写不再直读旧列)
render: (h: any, { row }: any) => h('span', row.identityNames || '-'),
},
{
title: '状态', key: 'disabled', width: 130, align: 'center',
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) {
// 正常 Tag + 转出徽标(view-ui-plus Badge status 模式:圆点 + 文本)
// 正常徽标 + 转出角标(view-ui-plus Badge status 模式:圆点 + 文本)
return h('div', { style: 'display:inline-flex;align-items:center;gap:6px' }, [
h('Tag', { color: 'success' }, '正常'),
h('Badge', { props: { status: 'warning', text: '转出' } }),
h(Tag, { class: 'tag-outline' }, () => '正常'),
h(Badge, { status: 'warning', text: '转出' }),
]);
}
return h('Tag', { color: 'success' }, '正常');
return h(Tag, { class: 'tag-outline' }, () => '正常');
},
},
{
title: '排序号', key: 'sortNo', width: 90, align: 'center',
render: (h: any, { row }: any) => h('span', row.sortNo == null ? '-' : row.sortNo),
},
{
title: '操作', key: 'action', width: 220, align: 'center', fixed: 'right' as const,
render: (h: any, { row }: any) => {
const opList: any[] = [
h('a', {
style: { marginRight: '8px', color: '#577afb' },
onClick: () => handleEdit(row),
}, '编辑'),
];
// 仅「单组浏览」(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);
},
},
// 文字链形态:颜色走通道 .operating-link = var(--brand),随主题档位变化;
// 排序仅「单组浏览」开放——withSub 是与行数据无关的全局模式开关,用 when 整组隐藏合规
actionColumn([
{ key: 'edit', label: '编辑', on: (r: any) => handleEdit(r) },
{ key: 'sort', label: '排序', on: (r: any) => openSort(r), when: () => dataParams.value.withSub === 0 },
{ key: 'del', label: '删除', on: (r: any) => handleDelete(r), danger: true },
], { mode: 'text', width: 180, fixed: 'right' }),
];
/* ================= 导出组用户 ================= */
@@ -367,10 +348,6 @@ function onSortSaved() {
</script>
<style lang="scss">
.group-user-page .page-panel {
left: 348px !important;
}
/* 左侧树容器改为 flex 纵向布局并收起自身滚动,把滚动条收敛到树节点区域
(滚动体是 MyTree 内部的 .ztree,见 components/MyTree.vue),
避免树过长时把顶部 opera-ul 操作按钮条滚出屏幕;仅作用于本页,不影响全局 .tree-container */
+2 -2
View File
@@ -158,10 +158,10 @@ async function handlePreview() {
}
.download-link {
color: #577afb;
color: var(--brand);
&:hover {
color: #3d5afe;
color: var(--brand-2);
}
}
+7 -19
View File
@@ -1,5 +1,5 @@
<template>
<div class="main-wrapper">
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="search-wrap fix">
<Input v-model="dataParams.keyword" placeholder="输入接口名/应用名/appKey搜索" clearable suffix="ios-search"
@@ -26,7 +26,7 @@
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
</div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
</div>
<OapiLogDetailModal v-model="detailVisible" :row="currentRow" />
@@ -35,15 +35,13 @@
<script setup lang="ts">
import { ref } from 'vue';
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import OapiLogDetailModal from './OapiLogDetailModal.vue';
import Utils from '@/utils';
import { actionColumn } from '@/utils/tableActions';
defineOptions({ name: 'OapiLogList' });
const { $utils } = useCurrentInstance();
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
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 }> = {
1: { text: '重要', className: 'text-error' },
2: { text: '次要', className: 'text-warning' },
@@ -164,18 +160,10 @@ const columns = [
width: 180,
render: (h: any, { row }: any) => h('span', row.createTime ? Utils.dateFormat(row.createTime, 'yyyy-MM-dd HH:mm:ss') : '-'),
},
{
title: '操作',
key: 'action',
width: 100,
align: 'center',
fixed: 'right' as const,
render: (h: any, { row }: any) =>
h('a', {
style: { color: '#577afb' },
onClick: () => openDetail(row),
}, '查看详情'),
},
// 文字链形态:颜色走通道 .operating-link = var(--brand),随主题档位变化
actionColumn([
{ key: 'detail', label: '查看详情', on: (r: any) => openDetail(r) },
], { mode: 'text', width: 100, fixed: 'right' }),
];
const detailVisible = ref(false);
+2 -2
View File
@@ -150,10 +150,10 @@ async function handlePreview() {
}
.download-link {
color: #577afb;
color: var(--brand);
&:hover {
color: #3d5afe;
color: var(--brand-2);
}
}
}
+8 -20
View File
@@ -1,5 +1,5 @@
<template>
<div class="main-wrapper">
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="search-wrap fix">
<Input v-model="dataParams.keyword" placeholder="接口名称 / 用户名" clearable suffix="ios-search"
@@ -27,7 +27,7 @@
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
</div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
</div>
<OperationLogDetailModal v-model="detailVisible" :row="currentRow" />
@@ -36,15 +36,13 @@
<script setup lang="ts">
import { ref } from 'vue';
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import OperationLogDetailModal from './OperationLogDetailModal.vue';
import Utils from '@/utils';
import { actionColumn } from '@/utils/tableActions';
defineOptions({ name: 'OperationLogList' });
const { $utils } = useCurrentInstance();
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
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 }> = {
1: { text: '重要', className: 'text-error' },
2: { text: '次要', className: 'text-warning' },
@@ -160,23 +156,15 @@ const columns = [
render: (h: any, { row }: any) => h('span', row.responseSize != null && row.responseSize > 0 ? Utils.getFileSize(row.responseSize) : '-'),
},
{
title: '操作时间ed',
title: '操作时间',
key: 'createTime',
width: 180,
render: (h: any, { row }: any) => h('span', row.createTime ? Utils.dateFormat(row.createTime, 'yyyy-MM-dd HH:mm:ss') : '-'),
},
{
title: '操作',
key: 'action',
width: 100,
align: 'center',
fixed: 'right' as const,
render: (h: any, { row }: any) =>
h('a', {
style: { color: '#577afb' },
onClick: () => openDetail(row),
}, '查看详情'),
},
// 文字链形态:颜色走通道 .operating-link = var(--brand),随主题档位变化
actionColumn([
{ key: 'detail', label: '查看详情', on: (r: any) => openDetail(r) },
], { mode: 'text', width: 100, fixed: 'right' }),
];
const detailVisible = ref(false);
+8 -32
View File
@@ -1,5 +1,5 @@
<template>
<div class="main-wrapper">
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="search-wrap fix">
<Select v-model="dataParams.appKey" class="ml-12" clearable filterable placeholder="应用"
@@ -27,7 +27,7 @@
<Button type="primary" class="ml-12" @click="handleSearch">查询</Button>
</div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
</div>
<PushLogDetailModal v-model="detailVisible" :row="currentRow" />
@@ -37,15 +37,13 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { Message } from 'view-ui-plus';
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import Utils from '@/utils';
import PushLogDetailModal from './PushLogDetailModal.vue';
import { actionColumn } from '@/utils/tableActions';
defineOptions({ name: 'PushLog' });
const { $utils } = useCurrentInstance();
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
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 = [
{ type: 'index', title: '序号', width: 70, align: 'center' },
{ title: '应用', key: 'appName', ellipsis: true, tooltip: true },
@@ -163,23 +159,11 @@ const columns = [
align: 'right',
render: (h: any, { row }: any) => h('span', row.payloadSize != null && row.payloadSize > 0 ? Utils.getFileSize(row.payloadSize) : '-'),
},
{
title: '操作',
key: 'action',
width: 150,
align: 'left',
render: (h: any, { row }: any) => {
const actions = [
h('a', { style: { color: '#577afb', marginRight: '12px' }, onClick: () => openDetail(row) }, '查看详情'),
];
if (row.success !== 1) {
actions.push(
h('a', { style: { color: '#ed4014' }, onClick: () => handleRetry(row) }, '重试')
);
}
return h('span', actions);
},
},
// 重试按行数据(成功与否)变化 → 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);
@@ -225,12 +209,4 @@ loadApps();
vertical-align: middle;
}
}
.text-success {
color: #19be6b;
}
.text-error {
color: #ed4014;
}
</style>
+2 -2
View File
@@ -169,10 +169,10 @@ function formatTime(val: any): string {
}
.download-link {
color: #577afb;
color: var(--brand);
&:hover {
color: #3d5afe;
color: var(--brand-2);
}
}
}
-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>
+47 -12
View File
@@ -26,9 +26,10 @@
: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-count">{{ item.treeData.length }}个应用</span>
<span class="cat-spacer"></span>
<!-- 开关外层 @click.stop:否则点击会冒泡到卡片头部触发折叠 -->
<span class="expand-all" @click.stop>
<span class="expand-all-label">全部展开</span>
<span class="expand-all-label">全部展开:</span>
<Switch size="small" v-model="expandAllMap[String(item.cat.id)]" />
</span>
</div>
@@ -63,7 +64,7 @@ type AppModuleVo = {
checked?: boolean;
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 AuthzTreeNode = {
id: string;
@@ -117,12 +118,16 @@ function countTotalModules(nodes: AppModuleVo[]): number {
}
function isAppChecked(app: AppItem): boolean {
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 {
@@ -131,7 +136,7 @@ function buildModuleNode(m: AppModuleVo, appId: number): AuthzTreeNode {
id: `mod_${m.id}`,
name: String(m.name || ""),
open: true,
checked: !!m.checked,
checked: isModuleAnyChecked(m),
nodeType: "module",
rawId: Number(m.id),
appId,
@@ -254,7 +259,12 @@ function onTreeCheck(node: any) {
// zTree 原生级联已生效,把该应用最终勾选态同步回本地数据(saveNow 的提交依据)
const app = findLocalApp(node.appId);
if (!app) return;
if ((app.modules || []).length === 0) {
// 无子模块应用:没有模块明细可同步,直接记整应用勾选态
app.checked = !!node.checked;
} else {
syncAppModulesFromTree(app, node);
}
scheduleSave();
}
@@ -264,7 +274,14 @@ const saveNow = async () => {
const moduleIds: number[] = [];
for (const cat of categoryList.value || []) {
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 {
@@ -298,7 +315,7 @@ watch(
.role-authz-tree {
position: relative;
flex: 1;
min-height: 320px;
min-height: 0;
padding: 0;
background: #fff;
overflow: auto;
@@ -342,7 +359,7 @@ watch(
}
.role-name {
color: #2d8cf0;
color: var(--brand);
font-weight: 600;
}
@@ -393,6 +410,9 @@ watch(
font-size: 13px;
color: #333;
margin-left: 6px;
/* 列宽有限:分类名优先让位(省略号),保证右侧「全部展开」不被挤出可视区 */
flex: 0 999 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -402,10 +422,18 @@ watch(
color: #999;
font-size: 12px;
margin-left: 8px;
flex: 1;
flex: 0 10 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
/* 弹性空隙:把「全部展开」整组推到卡片头右端 */
.cat-spacer {
flex: 1 1 0;
min-width: 0;
}
.cat-card-body {
padding: 6px 8px;
}
@@ -429,6 +457,13 @@ watch(
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) {
max-width: calc(100% - 30px);
overflow: hidden;
+31 -23
View File
@@ -1,7 +1,7 @@
<template>
<div class="page-wrapper role-authz-wrapper">
<div class="page-wrapper tree-wrapper role-authz-wrapper">
<!-- 左:角色列表(可滚动,应对大量角色;顶部 2 个筛选) -->
<div class="role-list-panel">
<div class="tree-container role-list-panel">
<div class="role-filter">
<Select v-model="filterAppId" placeholder="按绑定应用筛选" filterable clearable
@on-change="onFilterChange">
@@ -18,7 +18,7 @@
:class="['role-item', selectedRoleId === role.id ? 'active' : '']" @click="selectRole(role)">
<div class="role-name">{{ role.name }}</div>
<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>
</div>
</div>
@@ -31,12 +31,12 @@
</div>
<!-- 右:选中角色的授权面板(应用授权 / 用户管理) -->
<div class="authz-panel">
<div class="main-container authz-panel">
<div v-if="selectedRole" class="authz-inner">
<div class="authz-bar">
<span class="authz-title">当前角色:<b>{{ selectedRole.name }}</b></span>
<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>
<Tag v-if="selectedRole.disabled" color="red">已禁用</Tag>
@@ -152,19 +152,12 @@ function goManage() {
</script>
<style lang="scss" scoped>
.role-authz-wrapper {
display: flex;
height: 100%;
background: #f5f7fa;
}
/* 通道 tree-wrapper 骨架:外边界 16 / 两卡间隙 16 / 等高 stretch,高度对齐列表页 */
.role-list-panel {
width: 300px;
flex-shrink: 0;
padding: 0;
display: flex;
flex-direction: column;
background: #ffffff;
border-right: 1px solid #ebedf0;
overflow: hidden;
}
@@ -196,12 +189,12 @@ function goManage() {
transition: all 0.15s;
&:hover {
border-color: #b8c6ff;
border-color: var(--brand-2);
}
&.active {
border-color: #577afb;
background: #f3f6ff;
border-color: var(--brand);
background: var(--brand-soft);
}
}
@@ -235,18 +228,21 @@ function goManage() {
border-top: 1px solid #eeeeee;
.ivu-btn-text {
color: #577afb;
color: var(--brand);
}
}
.authz-panel {
flex: 1;
min-width: 0;
overflow: auto;
padding: 0 16px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.authz-inner {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding-top: 12px;
}
@@ -255,10 +251,18 @@ function goManage() {
align-items: center;
gap: 10px;
padding: 8px 0 4px;
flex: none;
}
.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 {
@@ -268,9 +272,13 @@ function goManage() {
.authz-tabs {
margin-top: 6px;
flex: none;
}
.authz-tab-content {
flex: 1;
min-height: 0;
overflow: auto;
padding-top: 8px;
}
</style>
+2 -2
View File
@@ -158,10 +158,10 @@ function goAuthz() {
text-align: center;
font-size: 12px;
cursor: pointer;
color: #577afb;
color: var(--brand);
&:hover {
color: #6c91f0;
color: var(--brand-2);
}
}
+5 -7
View File
@@ -16,11 +16,9 @@
import { ref, computed, watch } from "vue";
import { Message } from "view-ui-plus";
import Apis from "@/api";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import MyConfirm from "@/components/MyConfirm";
import RoleUserBindModal from "./RoleUserBindModal.vue";
const { $utils } = useCurrentInstance();
import { actionColumn } from "@/utils/tableActions";
const props = defineProps<{
roleId: number;
@@ -44,10 +42,10 @@ const columns = [
{ title: "账号", key: "account", width: 110 },
{ title: "姓名", key: "realName", width: 90 },
{ title: "部门", key: "groupPath", minWidth: 200 },
{
title: "操作", key: "action", width: 80, align: "center",
render: (h: any, { row }: any) => $utils.operatingTextHandle(h, "删除", "text-error", () => removeUser(row)),
},
// UserBase 恒文字链(工程风格显式声明):单删除动作保持红色文字链,列宽 80 不变
actionColumn([
{ key: "del", label: "删除", icon: "icon-ashbin", on: (r: any) => removeUser(r), danger: true },
], { mode: "text", width: 80 }),
];
async function loadUsers() {
+26 -9
View File
@@ -99,15 +99,32 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
server: {
host: "localhost",
port: 8130,
// proxy: {
// // //api是自行设置的请求前缀,任何请求路径以/api开头的请求将被代理到对应的target目标
// // "/api": {
// // target: "http://localhost:8064/",
// // // target: "http://debugold.hongyucloud.com/ChildOptCourse",
// // changeOrigin: true, //需要代理跨域
// // rewrite: (path: any) => path.replace(/^\/api/, "/api/"), //路径重写,把'/api'替换为''
// // },
// },
proxy: {
// 主题通道本地联调(唯一必需项):dev 下 index.html 的 loader 走同源相对(见其注释的优先级四档),
// manifest/CSS 经此代理到中央通道;changeOrigin 顺带把 Host 改写成目标域,Caddy 按 Host 匹配站点。
// 联调本地通道源:YschTheme 侧 npm run serve:static(默认 9527),再用
// YH_THEME_PROXY_TARGET=http://localhost:9527 覆盖本 target。prod 同源用不到这段。
"/static": {
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: {