# 系统权限管理脑图拖动画布与维护页表格化 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:

```js
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:

```js
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:

```js
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:

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

Expected result:

```text
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:

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

with:

```vue
<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:

```js
import { computed, ref } from "vue";
```

with:

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

Extend props:

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

Replace emits:

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

with:

```js
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;`:

```js
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`:

```js
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`:

```js
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:

```css
.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:

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

Expected result:

```text
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:

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

with:

```vue
<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:

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

this line:

```js
const mindMapFitRequestKey = ref(0);
```

- [ ] **Step 3: Relax manual zoom minimum**

Replace:

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

with:

```js
mindMapScale.value = Math.min(1.8, Math.max(0.12, nextScale));
```

- [ ] **Step 4: Apply fit scale from child component**

Add below `zoomMindMap`:

```js
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:

```js
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:

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

Expected result:

```text
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:

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

with:

```js
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:

```js
const permissionMainTab = ref("org");
```

this line:

```js
const maintenanceSubTab = ref("users");
```

Add below `filteredManagerOptions`:

```js
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:

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

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

```vue
<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:

```css
.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`:

```css
.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:

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

Add this replacement inside the same media block:

```css
.system-permission-sub-tabs {
  width: 100%;
  grid-template-columns: 1fr;
}
```

- [ ] **Step 5: Run static test and confirm it passes**

Run:

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

Expected result:

```text
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:

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

Expected result:

```text
system permission management UI static assertions passed
```

- [ ] **Step 2: Run frontend build**

Run:

```bash
npm run build
```

from:

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

Expected result:

```text
✓ 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:

```text
http://127.0.0.1:5173/system-permissions
```

Verify these UI behaviors:

```text
组织脑图：
- 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.
