ForgeFlow-ERP/docs/superpowers/plans/2026-06-08-system-permission-mindmap-pan-maintenance-tables.md
2026-06-12 16:00:56 +08:00

22 KiB
Raw Permalink Blame History

系统权限管理脑图拖动画布与维护页表格化 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 让系统权限管理中的组织脑图支持拖动画布和刷新后自动适配一屏,同时把“人员与角色维护”改成“系统人员 / 角色维护”子页签加全局表格分页样式。

Architecture: SystemPermissionView.vue 继续负责页面状态、页签、分页和工具栏;OrgMindMap.vue 只负责脑图布局、画布视口拖动和根据父页面请求计算适配缩放。测试沿用现有静态断言脚本,先新增失败断言,再逐步实现。

Tech Stack: Vue 3 <script setup>、现有 PaginationBar、现有 usePagination、Vite、Node 静态断言测试。


File Structure

  • Modify: frontend/scripts/test-system-permission-management-ui.mjs
    • 增加脑图画布拖动、刷新自适应、维护页子页签、表格分页的静态断言。
  • Modify: frontend/src/components/OrgMindMap.vue
    • 增加画布视口 ref、鼠标拖动画布滚动、适配一屏计算、向父页面回传缩放比例。
  • Modify: frontend/src/views/SystemPermissionView.vue
    • 传入脑图适配请求 key刷新后触发适配把维护页左右卡片改为子页签、表格、分页。
  • No SQL changes.
  • No backend changes.

Task 1: Write Failing Static Assertions

Files:

  • Modify: frontend/scripts/test-system-permission-management-ui.mjs

  • Step 1: Add failing assertions for the new interaction and layout requirements

Add the following assertions after the existing mindMapScale / refreshMindMap assertions:

assert.match(systemPermissionSource, /mindMapFitRequestKey/, "page should request one-screen mind-map fitting");
assert.match(
  systemPermissionSource,
  /:fit-request-key="mindMapFitRequestKey"/,
  "SystemPermissionView should pass fit request key to OrgMindMap"
);
assert.match(
  systemPermissionSource,
  /@fit-scale="applyMindMapFitScale"/,
  "SystemPermissionView should accept fit scale from OrgMindMap"
);
assert.match(
  systemPermissionSource,
  /function applyMindMapFitScale/,
  "SystemPermissionView should apply one-screen fit scale"
);
assert.match(
  systemPermissionSource,
  /mindMapFitRequestKey\.value \+= 1/,
  "refresh should request a one-screen fit after reloading mind-map data"
);

Add the following assertions after the existing 人员与角色维护 assertions:

assert.match(systemPermissionSource, /maintenanceSubTab/, "maintenance page should have nested personnel and role tabs");
assert.match(systemPermissionSource, /system-permission-sub-tabs/, "maintenance page should render nested sub tabs");
assert.match(
  systemPermissionSource,
  /v-if="maintenanceSubTab === 'users'"/,
  "system users should render in their own maintenance sub tab"
);
assert.match(
  systemPermissionSource,
  /v-if="maintenanceSubTab === 'roles'"/,
  "roles should render in their own maintenance sub tab"
);
assert.match(systemPermissionSource, /import PaginationBar from "\.\.\/components\/PaginationBar\.vue";/, "page should use global PaginationBar");
assert.match(systemPermissionSource, /import \{ usePagination \} from "\.\.\/utils\/pagination";/, "page should use global pagination helper");
assert.match(systemPermissionSource, /paginatedUsers/, "system users table should use paginated rows");
assert.match(systemPermissionSource, /paginatedRoles/, "roles table should use paginated rows");
assert.match(systemPermissionSource, /<table class="data-table compact-table">/, "maintenance sub tabs should use global table styling");
assert.doesNotMatch(systemPermissionSource, /class="maintenance-grid"/, "maintenance page should not use the old side-by-side card grid");
assert.doesNotMatch(systemPermissionSource, /<ul class="permission-list">[\s\S]*v-for="role in roles"/, "roles should not render as old card list");
assert.doesNotMatch(systemPermissionSource, /<ul class="permission-list">[\s\S]*v-for="user in users"/, "users should not render as old card list");

Add the following assertions near the existing OrgMindMap assertions:

assert.match(orgMindMapSource, /fitRequestKey/, "OrgMindMap should accept fit request key");
assert.match(orgMindMapSource, /fit-scale/, "OrgMindMap should emit fit scale");
assert.match(orgMindMapSource, /mindMapViewport/, "OrgMindMap should keep a viewport ref for canvas panning");
assert.match(orgMindMapSource, /isCanvasPanning/, "OrgMindMap should track canvas panning state");
assert.match(orgMindMapSource, /startCanvasPan/, "OrgMindMap should start canvas panning from empty canvas space");
assert.match(orgMindMapSource, /moveCanvasPan/, "OrgMindMap should move the viewport while panning");
assert.match(orgMindMapSource, /endCanvasPan/, "OrgMindMap should stop canvas panning");
assert.match(orgMindMapSource, /fitCanvasToViewport/, "OrgMindMap should fit canvas to visible viewport");
assert.match(orgMindMapSource, /window\.addEventListener\("mousemove", moveCanvasPan\)/, "OrgMindMap should pan canvas through window mousemove listener");
assert.match(orgMindMapSource, /window\.removeEventListener\("mousemove", moveCanvasPan\)/, "OrgMindMap should clean up canvas pan listener");
assert.match(orgMindMapSource, /closest\?\.\("\.org-mind-map-node"\)/, "canvas panning should not start from node cards");
assert.match(orgMindMapSource, /defineExpose/, "OrgMindMap should expose fit helper for explicit refresh handling if needed");
  • Step 2: Run the static test and confirm it fails for missing behavior

Run:

node frontend/scripts/test-system-permission-management-ui.mjs

Expected result:

AssertionError [ERR_ASSERTION]

The first failure should reference one of the new missing symbols, such as mindMapFitRequestKey, maintenanceSubTab, or fitRequestKey.


Task 2: Add Canvas Panning and One-Screen Fit to OrgMindMap

Files:

  • Modify: frontend/src/components/OrgMindMap.vue

  • Step 1: Extend the template with a draggable viewport

Replace the opening wrapper:

<div class="org-mind-map">

with:

<div
  ref="mindMapViewport"
  class="org-mind-map"
  :class="{ 'org-mind-map--panning': isCanvasPanning }"
  @mousedown="startCanvasPan"
>

Keep the SVG and node event handlers as they are. Do not add draggable="true" to any node.

  • Step 2: Extend script imports and props/emits

Replace:

import { computed, ref } from "vue";

with:

import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";

Extend props:

const props = defineProps({
  nodes: {
    type: Array,
    default: () => []
  },
  zoomScale: {
    type: Number,
    default: 1
  },
  fitRequestKey: {
    type: Number,
    default: 0
  }
});

Replace emits:

const emit = defineEmits(["select-node", "node-contextmenu", "toggle-employee-leaves"]);

with:

const emit = defineEmits(["select-node", "node-contextmenu", "toggle-employee-leaves", "fit-scale"]);
  • Step 3: Add viewport panning state

Add below const ROOT_GROUP_GAP = 1;:

const FIT_SCALE_MIN = 0.12;
const FIT_SCALE_MAX = 1;

const mindMapViewport = ref(null);
const isCanvasPanning = ref(false);
const canvasPan = ref({
  startX: 0,
  startY: 0,
  scrollLeft: 0,
  scrollTop: 0
});
  • Step 4: Add panning functions

Add below toggleEmployeeLeaves:

function shouldIgnoreCanvasPan(event) {
  const target = event.target;
  return Boolean(
    target?.closest?.(".org-mind-map-node") ||
      target?.closest?.(".org-mind-map-employee-toggle")
  );
}

function startCanvasPan(event) {
  if (event.button !== 0 || shouldIgnoreCanvasPan(event)) {
    return;
  }
  const viewport = mindMapViewport.value;
  if (!viewport) {
    return;
  }
  isCanvasPanning.value = true;
  canvasPan.value = {
    startX: event.clientX,
    startY: event.clientY,
    scrollLeft: viewport.scrollLeft,
    scrollTop: viewport.scrollTop
  };
  window.addEventListener("mousemove", moveCanvasPan);
  window.addEventListener("mouseup", endCanvasPan);
}

function moveCanvasPan(event) {
  if (!isCanvasPanning.value) {
    return;
  }
  const viewport = mindMapViewport.value;
  if (!viewport) {
    return;
  }
  const deltaX = event.clientX - canvasPan.value.startX;
  const deltaY = event.clientY - canvasPan.value.startY;
  viewport.scrollLeft = canvasPan.value.scrollLeft - deltaX;
  viewport.scrollTop = canvasPan.value.scrollTop - deltaY;
}

function endCanvasPan() {
  isCanvasPanning.value = false;
  window.removeEventListener("mousemove", moveCanvasPan);
  window.removeEventListener("mouseup", endCanvasPan);
}
  • Step 5: Add one-screen fit helper

Add below scaledCanvasStyle:

function centerCanvasInViewport() {
  const viewport = mindMapViewport.value;
  if (!viewport) {
    return;
  }
  viewport.scrollLeft = Math.max(0, (viewport.scrollWidth - viewport.clientWidth) / 2);
  viewport.scrollTop = Math.max(0, (viewport.scrollHeight - viewport.clientHeight) / 2);
}

async function fitCanvasToViewport() {
  await nextTick();
  const viewport = mindMapViewport.value;
  if (!viewport) {
    return;
  }
  const availableWidth = Math.max(1, viewport.clientWidth - 24);
  const availableHeight = Math.max(1, viewport.clientHeight - 24);
  const widthScale = availableWidth / canvasWidth.value;
  const heightScale = availableHeight / canvasHeight.value;
  const nextScale = clamp(Math.min(widthScale, heightScale), FIT_SCALE_MIN, FIT_SCALE_MAX);
  emit("fit-scale", Number(nextScale.toFixed(2)));
  requestAnimationFrame(centerCanvasInViewport);
}

watch(
  () => props.fitRequestKey,
  () => {
    if (props.fitRequestKey > 0) {
      fitCanvasToViewport();
    }
  },
  { immediate: true }
);

onBeforeUnmount(() => {
  endCanvasPan();
});

defineExpose({
  fitCanvasToViewport
});
  • Step 6: Update cursor styles

Replace the .org-mind-map style block with:

.org-mind-map {
  width: 100%;
  min-height: 460px;
  overflow: auto;
  border: 1px solid rgba(37, 99, 235, 0.14);
  border-radius: 24px;
  background:
    linear-gradient(135deg, rgba(248, 250, 252, 0.96), rgba(219, 234, 254, 0.46)),
    radial-gradient(circle at 1px 1px, rgba(37, 99, 235, 0.16) 1px, transparent 0);
  background-size:
    auto,
    22px 22px;
  cursor: grab;
  user-select: none;
}

.org-mind-map--panning {
  cursor: grabbing;
}

Keep .org-mind-map-node { cursor: pointer; } so node cards do not visually look draggable.

  • Step 7: Run the static test and confirm Task 2 assertions pass or fail only on parent-page assertions

Run:

node frontend/scripts/test-system-permission-management-ui.mjs

Expected result:

AssertionError [ERR_ASSERTION]

The remaining failure should be in SystemPermissionView.vue, because parent refresh and maintenance tabs are not implemented yet.


Task 3: Wire Refresh to One-Screen Fit in SystemPermissionView

Files:

  • Modify: frontend/src/views/SystemPermissionView.vue

  • Step 1: Pass fit request key and fit-scale handler to OrgMindMap

Replace:

<OrgMindMap
  :key="mindMapRenderKey"
  :nodes="orgNodes"
  :zoom-scale="mindMapScale"
  @select-node="selectOrgNode"
  @node-contextmenu="openContextMenu"
  @toggle-employee-leaves="selectOrgNode"
/>

with:

<OrgMindMap
  :key="mindMapRenderKey"
  :nodes="orgNodes"
  :zoom-scale="mindMapScale"
  :fit-request-key="mindMapFitRequestKey"
  @select-node="selectOrgNode"
  @node-contextmenu="openContextMenu"
  @toggle-employee-leaves="selectOrgNode"
  @fit-scale="applyMindMapFitScale"
/>
  • Step 2: Add fit request state

Add below:

const mindMapScale = ref(1);
const mindMapRenderKey = ref(0);

this line:

const mindMapFitRequestKey = ref(0);
  • Step 3: Relax manual zoom minimum

Replace:

mindMapScale.value = Math.min(1.8, Math.max(0.6, nextScale));

with:

mindMapScale.value = Math.min(1.8, Math.max(0.12, nextScale));
  • Step 4: Apply fit scale from child component

Add below zoomMindMap:

function applyMindMapFitScale(scale) {
  const numericScale = Number(scale || 1);
  mindMapScale.value = Math.min(1, Math.max(0.12, numericScale));
}
  • Step 5: Trigger one-screen fit after refresh

Replace refreshMindMap with:

async function refreshMindMap() {
  closeContextMenu();
  selectedNode.value = null;
  mindMapRenderKey.value += 1;
  await loadAll();
  mindMapFitRequestKey.value += 1;
  notifySuccess("脑图已刷新并适配一屏");
}
  • Step 6: Run the static test and confirm remaining failures only relate to maintenance tables

Run:

node frontend/scripts/test-system-permission-management-ui.mjs

Expected result:

AssertionError [ERR_ASSERTION]

The remaining failure should reference maintenanceSubTab, PaginationBar, paginatedUsers, paginatedRoles, maintenance-grid, or old permission-list rendering.


Task 4: Convert Maintenance Page to Nested Tabs, Tables, and Pagination

Files:

  • Modify: frontend/src/views/SystemPermissionView.vue

  • Step 1: Add imports

Replace:

import OrgMindMap from "../components/OrgMindMap.vue";
import { deleteResource, fetchResource, postResource, putResource } from "../services/api";

with:

import OrgMindMap from "../components/OrgMindMap.vue";
import PaginationBar from "../components/PaginationBar.vue";
import { deleteResource, fetchResource, postResource, putResource } from "../services/api";
import { usePagination } from "../utils/pagination";
  • Step 2: Add maintenance sub-tab state and paginated rows

Add below:

const permissionMainTab = ref("org");

this line:

const maintenanceSubTab = ref("users");

Add below filteredManagerOptions:

const userRowsForTable = computed(() => users.value);
const roleRowsForTable = computed(() => roles.value);

const {
  page: userPage,
  pageSize: userPageSize,
  rows: paginatedUsers
} = usePagination(userRowsForTable);

const {
  page: rolePage,
  pageSize: rolePageSize,
  rows: paginatedRoles
} = usePagination(roleRowsForTable);
  • Step 3: Replace the old side-by-side maintenance section

Replace the entire block starting with:

<section v-if="permissionMainTab === 'maintenance'" class="maintenance-grid">

and ending at its matching closing </section> before the context menu block with:

<section v-if="permissionMainTab === 'maintenance'" class="system-permission-maintenance-card full-width-card">
  <div class="section-head">
    <div>
      <p class="eyebrow">账号角色</p>
      <h3>人员与角色维护</h3>
      <p class="permission-note">系统人员用于登录;角色维护用于配置菜单权限和业务身份。</p>
    </div>
    <div class="row-actions">
      <button
        v-if="maintenanceSubTab === 'users'"
        class="primary-button"
        type="button"
        @click="openUserDrawer"
      >
        新增系统使用人员
      </button>
      <button
        v-else
        class="primary-button"
        type="button"
        @click="openRoleDrawer()"
      >
        新增角色
      </button>
    </div>
  </div>

  <div class="system-permission-sub-tabs" role="tablist" aria-label="人员与角色维护子页签">
    <button
      type="button"
      :class="{ active: maintenanceSubTab === 'users' }"
      @click="maintenanceSubTab = 'users'"
    >
      系统人员
    </button>
    <button
      type="button"
      :class="{ active: maintenanceSubTab === 'roles' }"
      @click="maintenanceSubTab = 'roles'"
    >
      角色维护
    </button>
  </div>

  <div v-if="maintenanceSubTab === 'users'" class="maintenance-table-panel">
    <p class="permission-note">新增系统使用人员时请从现有人员中选择电话号码作为登录账号密码必填最少6位。</p>
    <div class="table-wrap">
      <table class="data-table compact-table">
        <thead>
          <tr>
            <th>姓名</th>
            <th>电话号码 / 账号</th>
            <th>角色</th>
            <th>状态</th>
            <th>操作</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="user in paginatedUsers" :key="user.user_id || user.username">
            <td>{{ user.display_name || user.employee_name || user.username }}</td>
            <td>{{ user.mobile || user.username || "未设置电话号码" }}</td>
            <td>{{ formatRoleNames(user) }}</td>
            <td>{{ user.status === "ACTIVE" ? "启用" : "停用" }}</td>
            <td class="action-row">
              <button class="ghost-button" type="button" @click="resetPassword(user)">重置密码</button>
              <button class="ghost-button" type="button" @click="toggleUserStatus(user)">
                {{ user.status === "ACTIVE" ? "停用" : "启用" }}
              </button>
            </td>
          </tr>
          <tr v-if="!users.length">
            <td colspan="5" class="empty-row">暂无系统人员数据</td>
          </tr>
        </tbody>
      </table>
    </div>
    <PaginationBar v-model:page="userPage" v-model:page-size="userPageSize" :total="users.length" />
  </div>

  <div v-if="maintenanceSubTab === 'roles'" class="maintenance-table-panel">
    <div class="table-wrap">
      <table class="data-table compact-table">
        <thead>
          <tr>
            <th>角色名称</th>
            <th>角色编码</th>
            <th>系统人员数</th>
            <th>状态</th>
            <th>操作</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="role in paginatedRoles" :key="role.role_id || role.role_code || role.role_name">
            <td>{{ role.role_name || role.name || "未命名角色" }}</td>
            <td>{{ role.role_code || "未设置编码" }}</td>
            <td>{{ role.user_count || 0 }} 人</td>
            <td>{{ role.status === "INACTIVE" ? "停用" : "启用" }}</td>
            <td class="action-row">
              <button class="ghost-button" type="button" @click="openRoleDrawer(role)">编辑</button>
            </td>
          </tr>
          <tr v-if="!roles.length">
            <td colspan="5" class="empty-row">暂无角色数据</td>
          </tr>
        </tbody>
      </table>
    </div>
    <PaginationBar v-model:page="rolePage" v-model:page-size="rolePageSize" :total="roles.length" />
  </div>
</section>
  • Step 4: Replace maintenance CSS

Remove the .maintenance-grid style block:

.maintenance-grid {
  display: grid;
  grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
  gap: 20px;
}

Add below .system-permission-main-tabs button.active:

.system-permission-maintenance-card {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.system-permission-sub-tabs {
  display: inline-grid;
  grid-template-columns: repeat(2, minmax(150px, 1fr));
  gap: 8px;
  align-self: flex-start;
  padding: 6px;
  border: 1px solid rgba(37, 99, 235, 0.12);
  border-radius: 18px;
  background: #eef6ff;
}

.system-permission-sub-tabs button {
  border: 0;
  border-radius: 14px;
  padding: 10px 16px;
  background: transparent;
  color: #475569;
  font-weight: 800;
  cursor: pointer;
}

.system-permission-sub-tabs button.active {
  background: #ffffff;
  color: #1d4ed8;
  box-shadow: 0 12px 24px rgba(37, 99, 235, 0.14);
}

.maintenance-table-panel {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

In the @media (max-width: 980px) block, remove:

.maintenance-grid {
  grid-template-columns: 1fr;
}

Add this replacement inside the same media block:

.system-permission-sub-tabs {
  width: 100%;
  grid-template-columns: 1fr;
}
  • Step 5: Run static test and confirm it passes

Run:

node frontend/scripts/test-system-permission-management-ui.mjs

Expected result:

system permission management UI static assertions passed

Task 5: Build and Runtime Verification

Files:

  • Verify: frontend/src/components/OrgMindMap.vue

  • Verify: frontend/src/views/SystemPermissionView.vue

  • Verify: frontend/scripts/test-system-permission-management-ui.mjs

  • Step 1: Run the focused static test

Run:

node frontend/scripts/test-system-permission-management-ui.mjs

Expected result:

system permission management UI static assertions passed
  • Step 2: Run frontend build

Run:

npm run build

from:

/Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend

Expected result:

✓ built

The existing Vite chunk-size warning can remain.

  • Step 3: Browser smoke test if the local Vite server is running

If http://127.0.0.1:5173 is already running, open the in-app browser to:

http://127.0.0.1:5173/system-permissions

Verify these UI behaviors:

组织脑图:
- Right-click on a node still opens the node context menu.
- Left-click a node still selects it.
- Dragging from empty canvas space moves the viewport.
- Dragging from a node card does not move that node.
- Clicking 刷新 reloads and fits the whole mind map into one screen when the tree size allows it.

人员与角色维护:
- Top-level tab remains 组织脑图 / 人员与角色维护.
- 人员与角色维护 contains 系统人员 / 角色维护 child tabs.
- 系统人员 uses a data table plus PaginationBar.
- 角色维护 uses a data table plus PaginationBar.
- 新增 button changes with the active child tab.
  • Step 4: Report verification evidence

Report the exact commands run and their outcomes. If the browser smoke test is skipped because the local server is not running, state that it was skipped and that build/static verification passed.


Self-Review

  • Spec coverage: The plan covers canvas panning, node non-drag behavior, refresh-to-fit behavior, nested maintenance tabs, table layout, and global pagination.
  • Placeholder scan: This plan contains no unfinished placeholder markers and no unspecified future work.
  • Type consistency: mindMapFitRequestKey, fitRequestKey, fit-scale, applyMindMapFitScale, maintenanceSubTab, paginatedUsers, and paginatedRoles are defined before use in the plan.
  • Scope check: All changes are frontend-only and isolated to the system permission page, mind-map component, and the existing static test.