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