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

811 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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