Jelajahi Sumber

权限管理在portal处做一个独立的子系统

zengy 5 hari lalu
induk
melakukan
4bb310f7ac

+ 1 - 0
memory/index.md

@@ -34,6 +34,7 @@ npm run dev       # 默认 http://localhost:80
 
 ## 当前已知风险
 
+- 权限管理系统菜单迁移脚本已生成但尚未在目标 KingbaseES 执行;部署 PC 前端后执行 `pipe-network-service/sql/migration_permission_management_subsystem.sql`,重新登录以刷新动态路由(2026-10-15)。
 - 预警清单已接入发布和处置:可选事项、发布配置副本、三环节时限、处置原因多选。开发库已执行 `pipe-ner-server/pipe-network-service/sql/migration_warning_catalog_binding_kingbase.sql`,清单菜单及授权已核对,无菜单变更;主服务重启后页面闭环通过,测试数据清理完毕。采集自动关联仍需使用新版共享模块重新打包、重启并真实联调,详见后端 `docs/warning-catalog-binding-2026-09-29.md`。
 - 供水报警管理 #66 恢复独立只读分析页(流水、四级趋势、分类占比、台账),供水菜单按 rootTypeId=1 路由到该页;后端统一报警接口需同步部署设备报警状态及四态展示状态筛选,不能仅部署前端。
 

+ 1 - 1
memory/long-term.md

@@ -49,7 +49,7 @@
 - 工单仅在待派单(0)、待接单(1)时允许取消;移动端、PC 端展示和后端校验统一限制,保留既有取消权限与数据范围(2026-10-14)。
 - 移动端真实接口列表统一采用“首次进入加载 + 用户下拉刷新”,适合分页的业务列表再增加触底上拉分页;不因页面再次显示自动请求,不使用定时轮询。通讯录是明确的全量加载例外,不分页(2026-10-13)。
 - 移动端通讯录不限制部门范围,一次性返回全部状态正常、未删除的用户,并排除当前登录用户和超级管理员;移动端基于全量结果进行本地搜索(2026-10-13,取代部门范围及服务端分页方案)。
-- 移动端工单权限不依赖 PC 的 `ops:workOrder:query`:我的工单按创建人、责任人、核验人以及有派单权的待派单池取数,并叠加用户部门自身/祖先 101、102、103 对应设备父类型 1/2/3(共同包含 4)的公司范围;`device_id` 为空时创建人可见,派单后责任人和核验人按明确指派可见,其他派单人不可见。待办进一步按当前角色与状态收窄。流程权限统一为 `ops:workOrder:process`(接单/处置/提交验收/申请延期)和 `ops:workOrder:audit`(验收/延期审核),派单仍为 `ops:workOrder:dispatch`;派单候选责任人/核验人必须分别具有 process/audit 权限(2026-10-13)。
+- 移动端工单权限不依赖 PC 的 `ops:workOrder:query`。部门/公司设备范围只用于“待派单(0) + 当前用户拥有 `ops:workOrder:dispatch`”场景:无设备工单对所有有派单权限的用户可见、可派;有设备工单按用户部门自身/祖先 101、102、103 对应设备父类型 1/2/3 过滤,并共同包含共享类型 4。派单后不再按部门或设备公司过滤,工单管理按创建人、责任人、核验人控制可见,待办再按责任人/核验人与状态收窄;详情使用相同规则。流程权限统一为 `ops:workOrder:process`(接单/处置/提交验收/申请延期)和 `ops:workOrder:audit`(验收/延期审核),派单仍为 `ops:workOrder:dispatch`;派单候选责任人/核验人必须分别具有 process/audit 权限(2026-10-15,取代 2026-10-13 的公司范围全程过滤规则)。
 - `work_order.dept_id` 已废弃,工单归属和可见性不得再依赖该字段;PC `/api/ops/workOrder` 列表及状态统计对普通用户统一按 `create_by=当前用户 OR receive_user=当前用户 OR verify_user=当前用户` 过滤,超级管理员不受限制(2026-10-13)。
 - 工单处置仅允许“已接单(2)”和“延期中(6)”状态执行并进入“处理中(3)”;已经处于“处理中”的工单不得重复执行工单处置,应继续申请延期或提交验收(2026-10-13)。
 

+ 16 - 0
memory/sessions/2026-10-15.md

@@ -0,0 +1,16 @@
+# 2026-10-15
+
+## 权限管理子系统菜单
+
+- 目标:新增“权限管理系统”,将系统管理、系统监控、系统工具调整到该子系统下,并移除城市生命线驾驶舱系统。
+- 决策:复用原城市生命线驾驶舱的 `menu_id=2005`;首次迁移清理其原下级菜单和角色关联,再把 `menu_id=1/2/3` 挂到 2005 下。
+- 角色授权:拥有系统管理、系统监控、系统工具任一目录或下级菜单的角色,自动补授 2005 父菜单;原 2005 驾驶舱授权不继承。
+- 后端脚本:`pipe-network-service/sql/migration_permission_management_subsystem.sql`,面向 KingbaseES/PostgreSQL,可重复执行,附带执行后校验查询。
+- 前端:`src/views/portal/index.vue` 移除右上角“进入后台”,增加权限管理系统动态入口识别、锁形图标和名称;仅在当前用户动态菜单包含该子系统时展示,点击进入第一个可见菜单。右上角欢迎语显示当前登录用户 `nickName`,昵称为空时回退登录账号。
+- 验证:`npm run build:prod` 通过,仅有项目既有 Sass 弃用警告;数据库脚本未在用户数据库执行。
+
+## 移动端工单权限
+
+- 部门/公司设备范围仅用于待派单且当前用户拥有派单权限的工单。
+- 无设备待派单工单对所有有派单权限的用户可见、可派;有设备时按 101/102/103 对应 parent_id 1/2/3 并共同包含 4。
+- 派单后不再按部门过滤,工单管理按创建人、责任人、核验人控制,待办按责任人/核验人与状态进一步收窄,详情同步。

+ 4 - 2
src/layout/components/TagsView/index.vue

@@ -260,7 +260,9 @@ function toLastView(visitedViews, view) {
     const closedPath = view.path
     const prefix = closedPath.split('/')[1]
 
-    if (prefix === 'manholeCover') {
+    if (closedPath === '/subSystem/pipeNetwork' || closedPath.startsWith('/subSystem/pipeNetwork/')) {
+      router.push(getSubsystemHomePath('pipeNetwork'))
+    } else if (prefix === 'manholeCover') {
       router.push('/manholeCover/index')
     } else if (prefix === 'drainage') {
       router.push('/drainage/index')
@@ -269,7 +271,7 @@ function toLastView(visitedViews, view) {
     } else if (prefix === 'gas') {
       router.push('/gas/index')
     } else if (prefix === 'pipeNetwork') {
-      router.push('/pipeNetwork/index')
+      router.push(getSubsystemHomePath(prefix))
     } else if (prefix === 'dispatch') {
       router.push('/dispatch/index')
     } else {

+ 3 - 3
src/router/index.js

@@ -32,8 +32,8 @@ import { mergeRouteRecords } from './routeUtils'
  }
  */
 
-// 进入总后台后的默认落地页(原写死的 /index 首页菜单已移除)
-export const DEFAULT_BACKEND_HOME = '/system/user'
+// 访问站点根地址时统一进入系统门户,由用户选择有权限的子系统。
+export const DEFAULT_BACKEND_HOME = '/portal'
 
 // 公共路由
 export const constantRoutes = mergeRouteRecords(
@@ -76,7 +76,7 @@ export const constantRoutes = mergeRouteRecords(
     hidden: true
   },
 
-  // 根路径直接落到默认后台页面,不再保留写死的首页菜单
+  // 根路径统一进入系统门户,避免依赖某个用户未必拥有的后台菜单。
   {
     path: '/',
     redirect: DEFAULT_BACKEND_HOME,

+ 21 - 0
src/router/nestedRouteUtils.js

@@ -0,0 +1,21 @@
+/**
+ * Convert a flattened menu path into a path relative to its top-level route.
+ * Descendant menu records may still contain the legacy subsystem prefix.
+ */
+export function toNestedRoutePath(path, rootPath) {
+  const pathSegments = String(path || '').replace(/^\/+|\/+$/g, '').split('/').filter(Boolean)
+  const rootSegments = String(rootPath || '').replace(/^\/+|\/+$/g, '').split('/').filter(Boolean)
+
+  if (!rootSegments.length) return pathSegments.join('/')
+
+  const maxOverlap = Math.min(rootSegments.length, pathSegments.length)
+  for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
+    const rootSuffix = rootSegments.slice(rootSegments.length - overlap)
+    const pathPrefix = pathSegments.slice(0, overlap)
+    if (rootSuffix.every((segment, index) => segment === pathPrefix[index])) {
+      return pathSegments.slice(overlap).join('/')
+    }
+  }
+
+  return pathSegments.join('/')
+}

+ 2 - 2
src/router/sidebarRouteUtils.js

@@ -25,8 +25,8 @@ export function selectSidebarRoutes(currentPath, allRoutes = []) {
 }
 
 export function getSubsystemHomePath(prefix) {
-  if (prefix === 'waterSupply') {
-    return '/subSystem/waterSupply/index'
+  if (prefix === 'waterSupply' || prefix === 'pipeNetwork') {
+    return `/subSystem/${prefix}/index`
   }
   return `/${prefix}/index`
 }

+ 1 - 11
src/store/modules/permission.js

@@ -6,6 +6,7 @@ import ParentView from '@/components/ParentView'
 import InnerLink from '@/layout/components/InnerLink'
 import { ensureUniqueSubsystemRouteNames } from '@/router/routeNameUtils'
 import { resolveAlarmMenuTitle, resolveAlarmMenuView } from '@/router/alarmMenuView'
+import { toNestedRoutePath } from '@/router/nestedRouteUtils'
 import { joinRoutePath } from '@/utils/ruoyi'
 
 // 匹配views里面所有的.vue文件
@@ -115,17 +116,6 @@ function filterChildren(childrenMap, lastRouter = false, rootPath = '') {
   return children
 }
 
-function toNestedRoutePath(path, rootPath) {
-  const normalizedPath = String(path || '').replace(/^\/+|\/+$/g, '')
-  const normalizedRoot = String(rootPath || '').replace(/^\/+|\/+$/g, '')
-  if (!normalizedRoot) return normalizedPath
-  if (normalizedPath === normalizedRoot) return ''
-  if (normalizedPath.startsWith(`${normalizedRoot}/`)) {
-    return normalizedPath.slice(normalizedRoot.length + 1)
-  }
-  return normalizedPath
-}
-
 // 动态路由遍历,验证是否具备权限
 export function filterDynamicRoutes(routes) {
   const res = []

+ 1 - 1
src/views/login.vue

@@ -35,7 +35,7 @@
     </el-form>
     <!--  底部  -->
     <div class="el-login-footer">
-      <span>Copyright © 2018-2024 ruoyi.vip All Rights Reserved.</span>
+      <span>Copyright © 2026 沅陵县住房和城乡建设局 All Rights Reserved.</span>
     </div>
   </div>
 </template>

+ 25 - 10
src/views/portal/index.vue

@@ -7,8 +7,7 @@
       </div>
       <h1 class="system-title">沅陵县城市地下管网管控平台</h1>
       <div class="header-right">
-        <span class="welcome-text">欢迎,<span class="username">浏览账号</span></span>
-        <button class="header-btn" @click="enterBackend">进入后台</button>
+        <span class="welcome-text">欢迎,<span class="username">{{ userStore.nickName || userStore.name }}</span></span>
         <button class="header-btn" @click="logout">退出系统</button>
       </div>
     </header>
@@ -27,6 +26,7 @@
         <div class="icon-wrapper">
           <div class="icon-circle">
             <i :class="item.iconClass" class="module-icon"></i>
+            <span v-if="item.iconClass === 'icon-permission'" class="module-name">{{ item.name }}</span>
           </div>
         </div>
       </div>
@@ -37,7 +37,6 @@
 <script setup>
 import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
 import { useRouter } from 'vue-router'
-import { DEFAULT_BACKEND_HOME } from '@/router'
 import useUserStore from '@/store/modules/user'
 import usePermissionStore from '@/store/modules/permission'
 import { ElMessageBox } from 'element-plus'
@@ -70,6 +69,7 @@ const modules = ref([])
 const resolveModuleIcon = (route) => {
   const identity = [route.name, route.path, route.meta?.title].filter(Boolean).join(' ').toLowerCase()
   const iconMappings = [
+    { pattern: /权限管理|permissionmanagement/, iconClass: 'icon-permission' },
     { pattern: /生命线|lifecompany|citylifeline/, iconClass: 'icon-operation' },
     { pattern: /应急|指挥|dispatch/, iconClass: 'icon-sewage' },
     { pattern: /管网设施|pipenetwork/, iconClass: 'icon-bi' },
@@ -272,13 +272,6 @@ const loadDynamicModules = () => {
   updateAllModuleStyles()
 }
 
-// ==============================================
-// 进入后台
-// ==============================================
-const enterBackend = () => {
-  router.push(DEFAULT_BACKEND_HOME)
-}
-
 // ==============================================
 // 退出系统(与 Navbar 保持一致)
 // ==============================================
@@ -443,6 +436,7 @@ onUnmounted(() => {
         display: flex;
         align-items: center;
         justify-content: center;
+        position: relative;
 
         .module-icon {
           width: 150px;
@@ -452,6 +446,12 @@ onUnmounted(() => {
             background: url("../../assets/portal/smxyj.png") no-repeat center center;
             background-size: 100% 100%;
           }
+          &.icon-permission {
+            background: linear-gradient(180deg, #64efff 0%, #1595ff 100%);
+            filter: drop-shadow(0 0 10px rgba(38, 202, 255, 0.8));
+            mask: url("../../assets/icons/svg/lock.svg") no-repeat center 8px / 76px 76px;
+            -webkit-mask: url("../../assets/icons/svg/lock.svg") no-repeat center 8px / 76px 76px;
+          }
           &.icon-sewage {
             background: url("../../assets/portal/yjzh.png") no-repeat center center;
             background-size: 100% 100%;
@@ -477,6 +477,21 @@ onUnmounted(() => {
             background-size: 100% 100%;
           }
         }
+
+        .module-name {
+          position: absolute;
+          top: 98px;
+          left: 50%;
+          width: 180px;
+          color: #80efff;
+          font-size: 17px;
+          font-weight: 600;
+          line-height: 24px;
+          text-align: center;
+          text-shadow: 0 0 8px rgba(31, 197, 255, 0.8);
+          transform: translateX(-50%);
+          white-space: nowrap;
+        }
       }
     }
   }

+ 1 - 1
src/views/register.vue

@@ -70,7 +70,7 @@
     </el-form>
     <!--  底部  -->
     <div class="el-register-footer">
-      <span>Copyright © 2018-2024 ruoyi.vip All Rights Reserved.</span>
+      <span>Copyright © 2026 沅陵县住房和城乡建设局 All Rights Reserved.</span>
     </div>
   </div>
 </template>

+ 1 - 1
src/views/subSystem/alarm/AlarmHistory.vue

@@ -42,7 +42,7 @@ const groups = computed(() => groupAlarmHistory(props.records))
 .history-row { display: flex; gap: 12px; padding: 8px 0 8px 4px; }
 .history-time { position: relative; align-self: stretch; box-sizing: border-box; flex: 0 0 62px; padding: 1px 22px 0 0; font: 12px/1.6 ui-monospace, Consolas, monospace; color: #909399; }
 .history-time::before { content: ''; position: absolute; top: 5px; right: 8px; z-index: 1; width: 7px; height: 7px; border: 2px solid #fff; border-radius: 50%; background: #3b5bdb; box-shadow: 0 0 0 1px #9bb3d9; transform: translateX(50%); }
-.history-time::after { content: ''; position: absolute; top: 10px; right: 8px; z-index: 0; bottom: -27px; width: 1px; background: #d8e1ec; transform: translateX(50%); }
+.history-time::after { content: ''; position: absolute; top: 10px; right: 8px; z-index: 0; bottom: -27px; width: 1px; background: repeating-linear-gradient(to bottom, #3b5bdb 0 4px, transparent 4px 8px); transform: translateX(50%); }
 .history-row.last-log .history-time::after { display: none; }
 .history-body { flex: 1; min-width: 0; }
 .history-label { font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }

+ 1 - 1
src/views/subSystem/pipeNetwork/ops/workOrder/dispose.vue

@@ -1627,7 +1627,7 @@ onMounted(() => {
   z-index: 0;
   bottom: -27px;
   width: 1px;
-  background: #d8e1ec;
+  background: repeating-linear-gradient(to bottom, #3b5bdb 0 4px, transparent 4px 8px);
   transform: translateX(50%);
 }
 

+ 30 - 0
tests/nestedRouteUtils.test.mjs

@@ -0,0 +1,30 @@
+import assert from 'node:assert/strict'
+import { toNestedRoutePath } from '../src/router/nestedRouteUtils.js'
+
+const rootPath = '/subSystem/pipeNetwork'
+
+assert.equal(
+  toNestedRoutePath('/subSystem/pipeNetwork/pdeviceInform/netWorkBase', rootPath),
+  'pdeviceInform/netWorkBase',
+  'canonical absolute paths must become relative to the subsystem root'
+)
+
+assert.equal(
+  toNestedRoutePath('/pipeNetwork/pdeviceInform/netWorkBase', rootPath),
+  'pdeviceInform/netWorkBase',
+  'legacy pipeNetwork prefixes must not be duplicated below the new subsystem root'
+)
+
+assert.equal(
+  toNestedRoutePath('/pdeviceInform/netWorkBase', rootPath),
+  'pdeviceInform/netWorkBase',
+  'already-relative menu paths must remain unchanged'
+)
+
+assert.equal(
+  toNestedRoutePath('/assetInfo/pdeviceInform/netWorkBase', rootPath),
+  'assetInfo/pdeviceInform/netWorkBase',
+  'unrelated directory segments must remain intact'
+)
+
+console.log('nestedRouteUtils tests passed')

+ 13 - 0
tests/sidebarRouteUtils.test.mjs

@@ -16,6 +16,7 @@ const routes = [
   { path: '/index' },
   { path: '/system' },
   { path: '/subSystem/gas' },
+  { path: '/subSystem/pipeNetwork' },
   { path: '/subSystem/waterSupply' },
   { path: '/waterSupply' },
   { path: '/pipeNetwork' }
@@ -33,10 +34,22 @@ assert.deepEqual(
   'the water subsystem must display only its own dynamic route tree'
 )
 
+assert.deepEqual(
+  selectSidebarRoutes('/subSystem/pipeNetwork/index', routes).map(route => route.path),
+  ['/subSystem/pipeNetwork'],
+  'the pipe network subsystem must display only its own dynamic route tree'
+)
+
 assert.equal(
   getSubsystemHomePath('waterSupply'),
   '/subSystem/waterSupply/index',
   'water fallback navigation must target the backend route namespace'
 )
 
+assert.equal(
+  getSubsystemHomePath('pipeNetwork'),
+  '/subSystem/pipeNetwork/index',
+  'pipe network fallback navigation must target the backend route namespace'
+)
+
 console.log('sidebarRouteUtils tests passed')